feat(2meet-data-optimizer): support composite indexes on entity groups

- register_group() accepts $args['indexes'] (name => ordered keys); indexes on
  unknown keys are dropped. Kept outside the field definition, so schema
  hashes are unchanged.
- Schema_Manager::ensure_group_indexes() adds missing cidx_* indexes to the
  existing flat table in place; the applied definition is remembered in
  tmdo_entity_indexes so later requests skip SHOW INDEX.
- Bench (1M listings, scenario 2): Entity p50 337 ms -> 278 ms with
  (hp_verified, hp_price), on par with Hot (269 ms).
- Verify: core unit 656 / integration 459 green, phpcs clean
- PR: PR-027
This commit is contained in:
2026-09-26 01:05:23 +08:00
parent d6b2e417f4
commit 65c75dfc8d
5 changed files with 224 additions and 0 deletions
@@ -40,6 +40,9 @@ final class TMDO_Entity_Registry {
/** @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> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
private static array $collisions = array();
@@ -74,6 +77,7 @@ final class TMDO_Entity_Registry {
self::$unscoped_index = array();
self::$scoped_index = array();
self::$scopes = array();
self::$indexes = array();
self::$pending_schemas = array();
self::$collisions = array();
self::$restored = array();
@@ -119,6 +123,9 @@ final class TMDO_Entity_Registry {
* 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬;
* 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。
* 不列入欄位定義,所以不影響 schema hash 與衝突比對。
* indexes (array<string, string[]>) — 複合索引:名稱 → 欄位 key(依序);
* 含未知欄位的索引會被略過。同樣不影響 schema hash,
* 由 TMDO_Schema_Manager::ensure_group_indexes() 補建到既有表。
*
* @return bool
*/
@@ -151,6 +158,13 @@ final class TMDO_Entity_Registry {
if ( $post_types ) {
self::$scopes[ $entity_type ][ $group_name ] = $post_types;
}
$keys = array_column( $normalized, 'key' );
foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) {
$columns = array_values( array_map( 'strval', (array) $columns ) );
if ( $columns && ! array_diff( $columns, $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 );
}
@@ -372,6 +386,15 @@ final class TMDO_Entity_Registry {
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(未限定時為空陣列)。
*
@@ -20,6 +20,9 @@ defined( 'ABSPATH' ) || exit;
final class TMDO_Schema_Manager {
/** Option: "entity/group" → md5 of the composite index definition already applied. */
public const INDEXES_OPTION = 'tmdo_entity_indexes';
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
private static array $table_exists_cache = array();
@@ -60,6 +63,7 @@ final class TMDO_Schema_Manager {
$schema['group'],
$schema['fields']
);
self::ensure_group_indexes( $schema['type'], $schema['group'] );
}
TMDO_Entity_Registry::clear_pending_schemas();
@@ -164,6 +168,50 @@ final class TMDO_Schema_Manager {
return true;
}
/**
* Add the group's declared composite indexes that its table lacks.
*
* Indexes are not part of the schema hash, so an existing table (which
* may already hold aeav_only data) gets them in place — no rebuild. The
* applied definition is remembered per group (one autoloaded option), so
* requests only run SHOW INDEX / ALTER when the declaration changes.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return void
*/
public static function ensure_group_indexes( string $entity_type, string $group_name ): void {
global $wpdb;
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
if ( empty( $declared ) ) {
return;
}
$id = "{$entity_type}/{$group_name}";
$signature = md5( (string) wp_json_encode( $declared ) );
$applied = get_option( self::INDEXES_OPTION, array() );
$applied = is_array( $applied ) ? $applied : array();
if ( ( $applied[ $id ] ?? '' ) === $signature ) {
return;
}
$table = self::get_table_name( $entity_type, $group_name );
if ( ! self::table_exists( $table ) ) {
return;
}
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
foreach ( $declared as $name => $keys ) {
$index = 'cidx_' . self::sanitize_column_name( $name );
if ( in_array( $index, $existing, true ) ) {
continue;
}
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
$wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" );
}
$applied[ $id ] = $signature;
update_option( self::INDEXES_OPTION, $applied, true );
}
/**
* 計算 Schema Hash(用於偵測欄位變動)
*/