實體類型 → 適配器實例 */ private static array $adapters = array(); /** @var array> 實體類型 → 群組名 → 欄位定義陣列 */ private static array $groups = array(); /** @var array> 實體類型 → meta_key → 欄位定義(含 group 資訊)*/ private static array $field_index = array(); /** @var array 待建表的 Schema 清單 */ private static array $pending_schemas = array(); /** @var array>> 實體類型 → meta_key → 欄位定義(僅未限定 post_type 的群組)*/ private static array $unscoped_index = array(); /** @var array>>> 實體類型 → post_type → meta_key → 欄位定義 */ private static array $scoped_index = array(); /** @var array> 實體類型 → 群組名 → 限定的 post_type */ private static array $scopes = array(); /** @var array>> 實體類型 → 群組名 → 索引名 → 欄位 key */ private static array $indexes = array(); /** @var array> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */ private static array $collisions = array(); /** @var array> "entity/group" → 從 wpdo_registry_meta 還原的孤兒群組 */ private static array $restored = array(); /** 合法的欄位型別 */ public const VALID_TYPES = array( 'text', 'textarea', 'integer', 'decimal', 'boolean', 'date', 'datetime', 'timestamp', 'json', 'enum', 'binary', ); /** 合法的實體類型 */ public const VALID_ENTITY_TYPES = array( 'post', 'user', 'term', 'comment' ); /** Option:'1' 時 get_field() 依傳入的 post_type 解析(見 get_field())。 */ public const SCOPED_ROUTING_OPTION = 'tmdo_post_scoped_routing'; public static function init(): void { self::$adapters = array(); self::$groups = array(); self::$field_index = array(); self::$unscoped_index = array(); self::$scoped_index = array(); self::$scopes = array(); self::$indexes = array(); self::$pending_schemas = array(); self::$collisions = array(); self::$restored = array(); } // ───────────────────────────────────────────────────────── // 適配器管理 // ───────────────────────────────────────────────────────── public static function register_adapter( string $entity_type, TMDO_Entity_Adapter_Interface $adapter ): void { if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) { return; } self::$adapters[ $entity_type ] = $adapter; } public static function get_adapter( string $entity_type ): ?TMDO_Entity_Adapter_Interface { return self::$adapters[ $entity_type ] ?? null; } public static function get_all_adapters(): array { return self::$adapters; } // ───────────────────────────────────────────────────────── // 欄位群組登錄 // ───────────────────────────────────────────────────────── /** * 登錄一組 UAE 管理的欄位 * * @param string $entity_type post|user|term|comment * @param string $group_name 群組名稱(會成為表名的一部分) * @param array $fields 欄位定義陣列,每項需有 key、type,選填: * - required (bool) * - default (mixed) * - searchable (bool) 加索引 * - fulltext (bool) 全文索引(僅 text/textarea) * - unique (bool) 唯一索引 * - options (array) enum 選項 * - label (string) 顯示名稱 * @param array $args 選填:post_types (string[]) — 群組只套用於這些 post_type。 * 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬; * 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。 * 不列入欄位定義,所以不影響 schema hash 與衝突比對。 * indexes (array) — 複合索引:名稱 → 欄位 key(依序); * 含未知欄位的索引會被略過。同樣不影響 schema hash, * 由 TMDO_Schema_Manager::ensure_group_indexes() 補建到既有表。 * * @return bool */ public static function register_group( string $entity_type, string $group_name, array $fields, array $args = array() ): bool { if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) { return false; } if ( ! isset( self::$adapters[ $entity_type ] ) ) { return false; } $normalized = self::normalize_fields( $entity_type, $group_name, $fields ); if ( empty( $normalized ) ) { return false; } // 防重複登錄:相同定義(init:1 重新觸發)靜默略過;定義不同則記錄衝突,保留先註冊者。 if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) { if ( self::$groups[ $entity_type ][ $group_name ] !== $normalized ) { self::record_collision( $entity_type, $group_name, self::$groups[ $entity_type ][ $group_name ], $normalized ); } return false; } // 建立索引以供快速查詢 $post_types = array_values( array_filter( array_map( 'strval', (array) ( $args['post_types'] ?? array() ) ) ) ); if ( $post_types ) { self::$scopes[ $entity_type ][ $group_name ] = $post_types; } $keys = array_column( $normalized, 'key' ); // A sort index may end with the entity ID (flat-first tie-break, 2.4 PR-118). $id_col = (string) self::$adapters[ $entity_type ]->get_entity_id_column(); foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) { $columns = array_values( array_map( 'strval', (array) $columns ) ); $fields = '' !== $id_col && end( $columns ) === $id_col ? array_slice( $columns, 0, -1 ) : $columns; if ( $fields && ! array_diff( $fields, $keys ) ) { self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns; } } foreach ( $normalized as $normalized_field ) { self::index_field( $entity_type, $normalized_field, $post_types ); } self::$groups[ $entity_type ][ $group_name ] = $normalized; // 加入待建表佇列 self::$pending_schemas[] = array( 'type' => $entity_type, 'group' => $group_name, 'fields' => $normalized, ); return true; } /** * 驗證並正規化欄位定義(略過缺 key/type 或型別不合法者)。 * @param array> $fields * @return array> */ private static function normalize_fields( string $entity_type, string $group_name, array $fields ): array { $normalized = array(); foreach ( $fields as $field ) { if ( empty( $field['key'] ) || empty( $field['type'] ) ) { continue; } if ( ! in_array( $field['type'], self::VALID_TYPES, true ) ) { continue; } $normalized_field = wp_parse_args( $field, array( 'key' => '', 'type' => 'text', 'required' => false, 'default' => null, 'searchable' => false, 'fulltext' => false, 'unique' => false, 'options' => array(), 'label' => '', ) ); $normalized_field['group'] = $group_name; $normalized_field['entity_type'] = $entity_type; $normalized[] = $normalized_field; } return $normalized; } /** * 還原孤兒群組:依 wpdo_registry_meta 保存的定義重新登錄,只供讀寫路由用。 * * 與 register_group() 的差異:不進 pending_schemas(絕不觸發 DDL),且已被 * 其他存活群組管理的 key 一律略過,不覆蓋。$native_keys 列出原生 meta 仍有 * 資料的 key:那些值比扁平表新(孤兒期間寫入),必須留在原生 meta。 * * @param string[] $native_keys 原生 meta 仍有資料、不可還原的 key。 * @return bool 有登錄任何欄位時為 true * @param array> $fields */ public static function restore_group( string $entity_type, string $group_name, array $fields, array $native_keys = array() ): bool { if ( ! isset( self::$adapters[ $entity_type ] ) || isset( self::$groups[ $entity_type ][ $group_name ] ) ) { return false; } $kept = array(); $skipped = array(); $native = array(); foreach ( self::normalize_fields( $entity_type, $group_name, $fields ) as $field ) { if ( isset( self::$field_index[ $entity_type ][ $field['key'] ] ) ) { $skipped[] = (string) $field['key']; continue; } if ( in_array( $field['key'], $native_keys, true ) ) { $native[] = (string) $field['key']; continue; } $kept[] = $field; } $id = $entity_type . '/' . $group_name; if ( empty( $kept ) ) { // 整組都留在原生 meta 時仍要回報:扁平表的舊資料需要人工處理。 if ( $native ) { self::$restored[ $id ] = array( 'entity_type' => $entity_type, 'group' => $group_name, 'fields' => 0, 'skipped' => $skipped, 'native' => $native, ); } return false; } foreach ( $kept as $field ) { self::index_field( $entity_type, $field, array() ); } self::$groups[ $entity_type ][ $group_name ] = $kept; self::$restored[ $id ] = array( 'entity_type' => $entity_type, 'group' => $group_name, 'fields' => count( $kept ), 'skipped' => $skipped, 'native' => $native, ); return true; } /** * 把欄位加入索引。field_index 保留舊語意(整個實體共用、後註冊者覆蓋), * 供不帶 post_type 的查詢使用;另外依是否限定 post_type 分別建索引。 * * @param string[] $post_types 群組限定的 post_type;空陣列表示不限定。 * @param array $field */ private static function index_field( string $entity_type, array $field, array $post_types ): void { self::$field_index[ $entity_type ][ $field['key'] ] = $field; if ( empty( $post_types ) ) { self::$unscoped_index[ $entity_type ][ $field['key'] ] = $field; return; } foreach ( $post_types as $post_type ) { self::$scoped_index[ $entity_type ][ $post_type ][ $field['key'] ] = $field; } } /** * 本請求從 wpdo_registry_meta 還原的孤兒群組。 * * @return array */ public static function get_restored_groups(): array { return array_values( self::$restored ); } /** * 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。 * * 註冊每個請求都會跑,warning 以 transient 節流為每天一次,避免灌爆 wpdo_errors。 * @param array> $existing * @param array> $incoming */ private static function record_collision( string $entity_type, string $group_name, array $existing, array $incoming ): void { $id = $entity_type . '/' . $group_name; if ( isset( self::$collisions[ $id ] ) ) { return; } $old = array_column( $existing, null, 'key' ); $new = array_column( $incoming, null, 'key' ); $changed = array(); foreach ( array_intersect_key( $new, $old ) as $key => $field ) { if ( $field !== $old[ $key ] ) { $changed[] = (string) $key; } } self::$collisions[ $id ] = array( 'entity_type' => $entity_type, 'group' => $group_name, 'added' => array_map( 'strval', array_keys( array_diff_key( $new, $old ) ) ), 'removed' => array_map( 'strval', array_keys( array_diff_key( $old, $new ) ) ), 'changed' => $changed, ); $throttle = 'tmdo_group_collision_' . md5( (string) wp_json_encode( self::$collisions[ $id ] ) ); if ( class_exists( 'TMDO_Logger' ) && false === get_transient( $throttle ) ) { set_transient( $throttle, 1, DAY_IN_SECONDS ); TMDO_Logger::warning( 'entity_group_collision', self::$collisions[ $id ] ); } } /** * 本請求偵測到的同名群組衝突(第二份定義已被捨棄)。 * * @return array */ public static function get_collisions(): array { return array_values( self::$collisions ); } // ───────────────────────────────────────────────────────── // 查詢 API // ───────────────────────────────────────────────────────── /** * 依 meta_key 查詢欄位定義 * * 傳入 $post_type 且 SCOPED_ROUTING_OPTION 開啟時:先找限定該 post_type 的群組, * 再找不限定的群組,都沒有則為 null。否則沿用舊語意(整個實體共用索引, * 共用 key 由最後註冊的群組擁有)。舊資料可能已依舊語意散落在別的群組表, * 所以既有站要等資料對齊後才開啟 option。 * * @param string $post_type 選填:post 的 post_type。 * @return array|null 欄位定義,或 null(非 UAE 管理欄位) */ public static function get_field( string $entity_type, string $meta_key, string $post_type = '' ): ?array { if ( '' === $post_type || '1' !== (string) get_option( self::SCOPED_ROUTING_OPTION, '0' ) ) { return self::$field_index[ $entity_type ][ $meta_key ] ?? null; } return self::$scoped_index[ $entity_type ][ $post_type ][ $meta_key ] ?? self::$unscoped_index[ $entity_type ][ $meta_key ] ?? null; } /** * 取得實體類型下所有群組名稱 * * @return array */ public static function get_groups_for_type( string $entity_type ): array { return array_keys( self::$groups[ $entity_type ] ?? array() ); } /** * 群組宣告的複合索引。 * * @return array 索引名 → 欄位 key */ public static function get_group_indexes( string $entity_type, string $group_name ): array { return self::$indexes[ $entity_type ][ $group_name ] ?? array(); } /** * 群組限定的 post_type(未限定時為空陣列)。 * * @return string[] */ public static function get_group_post_types( string $entity_type, string $group_name ): array { return self::$scopes[ $entity_type ][ $group_name ] ?? array(); } /** * 取得特定群組的完整欄位定義 */ public static function get_group_fields( string $entity_type, string $group_name ): array { return self::$groups[ $entity_type ][ $group_name ] ?? array(); } /** * 取得一個群組中所有 meta_key * * @return array */ public static function get_group_keys( string $entity_type, string $group_name ): array { $fields = self::get_group_fields( $entity_type, $group_name ); return array_column( $fields, 'key' ); } /** * 取得所有已登錄的欄位(跨實體、跨群組) */ public static function get_all_fields(): array { return self::$field_index; } /** * 取得待建表清單(供 Schema_Manager 處理) */ public static function get_pending_schemas(): array { return self::$pending_schemas; } public static function clear_pending_schemas(): void { self::$pending_schemas = array(); } /** * 判斷某個 meta_key 是否由 UAE 管理 */ public static function is_managed( string $entity_type, string $meta_key ): bool { return isset( self::$field_index[ $entity_type ][ $meta_key ] ); } }