diff --git a/docs/BENCHMARK-SCALE.md b/docs/BENCHMARK-SCALE.md index 2097d4a..9230b06 100644 --- a/docs/BENCHMARK-SCALE.md +++ b/docs/BENCHMARK-SCALE.md @@ -56,6 +56,7 @@ SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個 3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。 4. **Hot 表的索引規則有漏洞。** `add_covering_indexes()` 只替 decimal、tinyint,以及名稱以 `_time` 結尾的 bigint 建索引,所以 `hp_vendor` 沒有索引,情境 3 要 3.4 秒,比 Entity 慢約 12,000 倍。 5. **Entity 表只有單欄索引。** 情境 2 輸給有複合索引的 Hot 表(337 ms 對 277 ms)。 + - **PR-027 之後**:群組可用 `register_group( ..., array( 'indexes' => ... ) )` 宣告複合索引。在 1M 列的 Entity 表加上 `cidx_verified_price (hp_verified, hp_price)` 花 8.1 秒;重跑情境 2(`--iterations=10`),Entity p50 從 337 ms 降到 **278 ms**(p95 285 ms),Hot 是 269 ms(p95 280 ms),差距在量測誤差內。執行計畫改走 `e:cidx_verified_price`。 6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。 7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。 diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php index 3e0a86f..51f258d 100644 --- a/includes/engine/class-tmdo-entity-registry.php +++ b/includes/engine/class-tmdo-entity-registry.php @@ -40,6 +40,9 @@ final class TMDO_Entity_Registry { /** @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(); @@ -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) — 複合索引:名稱 → 欄位 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 索引名 → 欄位 key + */ + public static function get_group_indexes( string $entity_type, string $group_name ): array { + return self::$indexes[ $entity_type ][ $group_name ] ?? array(); + } + /** * 群組限定的 post_type(未限定時為空陣列)。 * diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php index bd2fc78..448c21e 100644 --- a/includes/engine/class-tmdo-schema-manager.php +++ b/includes/engine/class-tmdo-schema-manager.php @@ -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(用於偵測欄位變動) */ diff --git a/tests/integration/GroupCompositeIndexTest.php b/tests/integration/GroupCompositeIndexTest.php new file mode 100644 index 0000000..4e4d4f5 --- /dev/null +++ b/tests/integration/GroupCompositeIndexTest.php @@ -0,0 +1,97 @@ +query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + $wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, ci_flag BIGINT(20) DEFAULT NULL, ci_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' ); + $wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' ); + $GLOBALS['_wp_options'] = array(); + + $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); + $ref->setAccessible( true ); + $ref->setValue( null, array() ); + + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Entity_Registry::register_group( + 'post', + 'ci_core', + array( + array( 'key' => 'ci_flag', 'type' => 'integer' ), + array( 'key' => 'ci_price', 'type' => 'decimal' ), + ), + array( 'indexes' => array( 'flag_price' => array( 'ci_flag', 'ci_price' ) ) ) + ); + } + + protected function tearDown(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + WPDO_Entity_Registry::init(); + } + + /** @return array index name → ordered columns */ + private function indexes(): array { + global $wpdb; + $out = array(); + foreach ( (array) $wpdb->get_results( 'SHOW INDEX FROM `' . self::TABLE . '`', ARRAY_A ) as $row ) { + $out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name']; + } + return $out; + } + + public function test_missing_composite_index_is_added_to_the_existing_table(): void { + TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' ); + + $this->assertSame( array( 'ci_flag', 'ci_price' ), $this->indexes()['cidx_flag_price'] ?? null ); + global $wpdb; + $this->assertSame( '2', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' ), 'data untouched' ); + } + + public function test_applied_definition_is_remembered_so_later_requests_skip_the_check(): void { + TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' ); + global $wpdb; + $wpdb->query( 'ALTER TABLE `' . self::TABLE . '` DROP INDEX `cidx_flag_price`' ); + + TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' ); + + $this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'same definition → no SHOW INDEX / ALTER' ); + $this->assertArrayHasKey( 'post/ci_core', get_option( TMDO_Schema_Manager::INDEXES_OPTION ) ); + } + + public function test_group_without_indexes_is_left_alone(): void { + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Entity_Registry::register_group( 'post', 'ci_core', array( array( 'key' => 'ci_flag', 'type' => 'integer' ) ) ); + + TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' ); + + $this->assertSame( array( 'PRIMARY', 'uk_entity' ), array_keys( $this->indexes() ) ); + $this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ) ); + } +} diff --git a/tests/unit/EntityRegistryIndexesTest.php b/tests/unit/EntityRegistryIndexesTest.php new file mode 100644 index 0000000..cb4a399 --- /dev/null +++ b/tests/unit/EntityRegistryIndexesTest.php @@ -0,0 +1,55 @@ +createMock( TMDO_Entity_Adapter_Interface::class ) ); + } + + private function fields(): array { + return array( + array( 'key' => 'x_flag', 'type' => 'integer' ), + array( 'key' => 'x_price', 'type' => 'decimal' ), + ); + } + + public function test_declared_indexes_are_kept_per_group(): void { + TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) ); + + $this->assertSame( array( 'flag_price' => array( 'x_flag', 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) ); + $this->assertSame( array(), TMDO_Entity_Registry::get_group_indexes( 'post', 'other' ) ); + } + + public function test_indexes_on_unknown_columns_are_dropped(): void { + TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'bad' => array( 'x_flag', 'nope' ), 'ok' => array( 'x_price' ) ) ) ); + + $this->assertSame( array( 'ok' => array( 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) ); + } + + public function test_indexes_leave_the_field_definition_and_hash_alone(): void { + TMDO_Entity_Registry::register_group( 'post', 'plain', $this->fields() ); + TMDO_Entity_Registry::register_group( 'post', 'indexed', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) ); + + $plain = TMDO_Schema_Manager::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( 'post', 'plain' ) ); + $indexed = TMDO_Schema_Manager::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( 'post', 'indexed' ) ); + $this->assertSame( $plain, $indexed ); + } + + public function test_init_clears_indexes(): void { + TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'ok' => array( 'x_price' ) ) ) ); + TMDO_Entity_Registry::init(); + + $this->assertSame( array(), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) ); + } +}