eac0955b76
- 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
458 lines
17 KiB
PHP
458 lines
17 KiB
PHP
<?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 ] );
|
||
}
|
||
}
|