Files
2meet-data-optimizer/includes/engine/class-tmdo-entity-registry.php
T
wpdev eac0955b76 perf(2meet-data-optimizer): let flat-first sorts use a (column, id) index
- a group index may end with the entity ID column (post_id, ...)
- flat-first rewrites the posts.ID tie-breaks once, in the sort's direction:
  with a (column, post_id) index MySQL returns rows in order instead of
  sorting the whole match. dev36 (1.3M listings), price range sorted by price:
  850 ms filesort -> 2 ms. Ties are ordered by ID the same way as the column
  (natively ID DESC); pagination stays stable, result sets are unchanged
- Verify: EntityRegistryIndexesTest, PostQueryRouterFoundRowsTest (ASC
  tie-break, single tie-break), unit 704, integration 589, phpcs, stan;
  dev36 with the HivePress indexes (PR-119)
- PR: PR-118
2026-09-27 22:40:59 +08:00

458 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_Entity_Registry - 全域欄位與適配器登錄中心
*
* 提供:
* - 適配器註冊(post/user/term/comment)
* - 欄位群組註冊(Schema 定義)
* - 欄位查詢 API(meta_key → field_def)
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Entity_Registry {
/** @var array<string, TMDO_Entity_Adapter_Interface> 實體類型 → 適配器實例 */
private static array $adapters = array();
/** @var array<string, array<string, array>> 實體類型 → 群組名 → 欄位定義陣列 */
private static array $groups = array();
/** @var array<string, array<string, array>> 實體類型 → meta_key → 欄位定義(含 group 資訊)*/
private static array $field_index = array();
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
private static array $pending_schemas = array();
/** @var array<string, array<string, array<string, mixed>>> 實體類型 → meta_key → 欄位定義(僅未限定 post_type 的群組)*/
private static array $unscoped_index = array();
/** @var array<string, array<string, array<string, array<string, mixed>>>> 實體類型 → post_type → meta_key → 欄位定義 */
private static array $scoped_index = array();
/** @var array<string, array<string, string[]>> 實體類型 → 群組名 → 限定的 post_type */
private static array $scopes = array();
/** @var array<string, array<string, array<string, string[]>>> 實體類型 → 群組名 → 索引名 → 欄位 key */
private static array $indexes = array();
/** @var array<string, array<string, mixed>> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
private static array $collisions = array();
/** @var array<string, array<string, mixed>> "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<string, mixed> $args 選填:post_types (string[]) — 群組只套用於這些 post_type。
* 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬;
* 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。
* 不列入欄位定義,所以不影響 schema hash 與衝突比對。
* indexes (array<string, string[]>) — 複合索引:名稱 → 欄位 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<int, array<string, mixed>> $fields
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $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<string, mixed> $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<int, array{entity_type:string, group:string, fields:int, skipped:string[], native:string[]}>
*/
public static function get_restored_groups(): array {
return array_values( self::$restored );
}
/**
* 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。
*
* 註冊每個請求都會跑,warning 以 transient 節流為每天一次,避免灌爆 wpdo_errors。
* @param array<int, array<string, mixed>> $existing
* @param array<int, array<string, mixed>> $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<int, array{entity_type:string, group:string, added:string[], removed:string[], changed:string[]}>
*/
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<string>
*/
public static function get_groups_for_type( string $entity_type ): array {
return array_keys( self::$groups[ $entity_type ] ?? array() );
}
/**
* 群組宣告的複合索引。
*
* @return array<string, string[]> 索引名 → 欄位 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<string>
*/
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 ] );
}
}