From 3f4fe52e1b5e900629b249093fd1b44486b78b29 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 23:55:49 +0800 Subject: [PATCH] fix(2meet-data-optimizer): route post entity keys by post_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity groups can declare post_types; get_field() resolves a post's key against its own type's groups first, then unscoped ones (legacy last-wins lookup kept for callers without a post type) - Hook Bus and the post query router pass the post type, so a listing's hp_rating / hp_status / hp_verified map to hp_listing_core instead of the vendor/request tables; core HP groups declare their post types (schema hashes unchanged) - Behind tmdo_post_scoped_routing: on for sites without post flat rows, off where rows may sit in the wrong table until PR-034 reconciles them (dry-run: dev20–22 off, dev23/24/30/31/40 on) - Verify: EntityRegistryScopeTest (8), PostScopedRoutingTest (4); core unit 630 / integration 443, HP 144/49, WC 10/22 OK - PR: PR-033 --- includes/class-tmdo-installer.php | 42 ++++++ .../engine/class-tmdo-entity-registry.php | 55 +++++++- includes/engine/class-tmdo-hook-bus.php | 23 +++- .../integrations/class-tmdo-post-fields.php | 9 +- .../query/class-tmdo-post-query-router.php | 2 +- tests/integration/PostScopedRoutingTest.php | 121 ++++++++++++++++++ tests/unit/EntityRegistryScopeTest.php | 102 +++++++++++++++ 7 files changed, 340 insertions(+), 14 deletions(-) create mode 100644 tests/integration/PostScopedRoutingTest.php create mode 100644 tests/unit/EntityRegistryScopeTest.php diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index 64c8e0f..7e97f73 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -268,6 +268,48 @@ class TMDO_Installer { self::install(); } self::maybe_preserve_misc_bucket_default(); + self::maybe_init_post_scoped_routing(); + } + + /** + * One-time default for post_type-scoped entity routing. + * + * Autoloaded because TMDO_Entity_Registry::get_field() reads it per lookup. + * + * @return void + */ + public static function maybe_init_post_scoped_routing(): void { + global $wpdb; + if ( false !== get_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, false ) || ! class_exists( 'TMDO_Mode_Manager' ) ) { + return; + } + + $has_rows = false; + $tables = (array) $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%' ) ); + foreach ( $tables as $table ) { + if ( $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from SHOW TABLES. + $has_rows = true; + break; + } + } + + add_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, self::post_scoped_routing_default( TMDO_Mode_Manager::get( 'post' ), $has_rows ), '', true ); + } + + /** + * Whether post_type-scoped routing can start on right away. + * + * Existing post flat rows were written with legacy routing (a key shared by + * several groups went to the last one registered), so they may sit in + * another post type's table. Scoped lookups stay off there until the rows + * are reconciled; with the post bridge disabled nothing reads them. + * + * @param string $post_mode Current post entity bridge mode. + * @param bool $has_post_flat_rows Whether any wpdo_post_* table has rows. + * @return string '1' to enable, '0' to wait for reconcile. + */ + public static function post_scoped_routing_default( string $post_mode, bool $has_post_flat_rows ): string { + return ( 'disabled' === $post_mode || ! $has_post_flat_rows ) ? '1' : '0'; } /** diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php index d355b46..8bcf47d 100644 --- a/includes/engine/class-tmdo-entity-registry.php +++ b/includes/engine/class-tmdo-entity-registry.php @@ -31,6 +31,12 @@ final class TMDO_Entity_Registry { /** @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 "entity/group" → 同名群組以不同定義重複註冊的紀錄 */ private static array $collisions = array(); @@ -55,10 +61,15 @@ final class TMDO_Entity_Registry { /** 合法的實體類型 */ 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::$pending_schemas = array(); self::$collisions = array(); self::$restored = array(); @@ -100,10 +111,14 @@ final class TMDO_Entity_Registry { * - unique (bool) 唯一索引 * - options (array) enum 選項 * - label (string) 顯示名稱 + * @param array $args 選填:post_types (string[]) — 群組只套用於這些 post_type。 + * 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬; + * 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。 + * 不列入欄位定義,所以不影響 schema hash 與衝突比對。 * * @return bool */ - public static function register_group( string $entity_type, string $group_name, array $fields ): 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; @@ -128,8 +143,9 @@ final class TMDO_Entity_Registry { } // 建立索引以供快速查詢 + $post_types = array_values( array_filter( array_map( 'strval', (array) ( $args['post_types'] ?? array() ) ) ) ); foreach ( $normalized as $normalized_field ) { - self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field; + self::index_field( $entity_type, $normalized_field, $post_types ); } self::$groups[ $entity_type ][ $group_name ] = $normalized; @@ -229,7 +245,7 @@ final class TMDO_Entity_Registry { } foreach ( $kept as $field ) { - self::$field_index[ $entity_type ][ $field['key'] ] = $field; + self::index_field( $entity_type, $field, array() ); } self::$groups[ $entity_type ][ $group_name ] = $kept; @@ -244,6 +260,24 @@ final class TMDO_Entity_Registry { return true; } + /** + * 把欄位加入索引。field_index 保留舊語意(整個實體共用、後註冊者覆蓋), + * 供不帶 post_type 的查詢使用;另外依是否限定 post_type 分別建索引。 + * + * @param string[] $post_types 群組限定的 post_type;空陣列表示不限定。 + */ + 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 還原的孤兒群組。 * @@ -305,10 +339,21 @@ final class TMDO_Entity_Registry { /** * 依 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 ): ?array { - return self::$field_index[ $entity_type ][ $meta_key ] ?? null; + 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; } /** diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index 6b25685..1f2f9d0 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -80,7 +80,7 @@ final class TMDO_Hook_Bus { return $check; } - $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key ); + $field_def = self::field_for( $type, (int) $object_id, $meta_key ); if ( ! $field_def ) { return $check; // 非管理欄位,放行 } @@ -133,7 +133,7 @@ final class TMDO_Hook_Bus { return $check; } - $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key ); + $field_def = self::field_for( $type, (int) $object_id, $meta_key ); if ( ! $field_def ) { return $check; } @@ -374,7 +374,7 @@ final class TMDO_Hook_Bus { return $check; } - $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key ); + $field_def = self::field_for( $type, (int) $object_id, $meta_key ); if ( ! $field_def ) { return $check; } @@ -471,7 +471,7 @@ final class TMDO_Hook_Bus { return $check; } - $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key ); + $field_def = self::field_for( $type, (int) $object_id, $meta_key ); if ( ! $field_def ) { return $check; } @@ -622,6 +622,19 @@ final class TMDO_Hook_Bus { return null; } + /** + * 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。 + * + * 先做不帶 post_type 的查詢:大多數 key 不受管理,可省下 get_post_type()。 + */ + private static function field_for( string $type, int $object_id, string $meta_key ): ?array { + $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key ); + if ( null === $field_def || 'post' !== $type ) { + return $field_def; + } + return TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) ); + } + /** * 直接讀取(繞過 WP filter 系統) * 供 wpdo_get_meta() 便利函式使用,性能更好 @@ -646,7 +659,7 @@ final class TMDO_Hook_Bus { return $result; } - $field_def = TMDO_Entity_Registry::get_field( $type, $key ); + $field_def = self::field_for( $type, $entity_id, $key ); if ( ! $field_def ) { return null; } diff --git a/includes/integrations/class-tmdo-post-fields.php b/includes/integrations/class-tmdo-post-fields.php index 6f2685d..c3e5574 100644 --- a/includes/integrations/class-tmdo-post-fields.php +++ b/includes/integrations/class-tmdo-post-fields.php @@ -340,7 +340,8 @@ final class TMDO_Post_Fields { 'type' => 'decimal', 'label' => 'Hourly rate', ), - ) + ), + array( 'post_types' => array( 'hp_listing' ) ) ); } @@ -384,7 +385,8 @@ final class TMDO_Post_Fields { 'type' => 'integer', 'label' => 'View counter', ), - ) + ), + array( 'post_types' => array( 'hp_request' ) ) ); } @@ -427,7 +429,8 @@ final class TMDO_Post_Fields { 'searchable' => true, 'label' => 'Average rating', ), - ) + ), + array( 'post_types' => array( 'hp_vendor' ) ) ); } diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index 6429769..ed4c5c3 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -110,7 +110,7 @@ class TMDO_Post_Query_Router { $matched = false; foreach ( $post_types as $pt ) { - $field = TMDO_Entity_Registry::get_field( 'post', $clause['key'] ); + $field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], (string) $pt ); if ( ! $field ) { continue; } diff --git a/tests/integration/PostScopedRoutingTest.php b/tests/integration/PostScopedRoutingTest.php new file mode 100644 index 0000000..f2ec7f7 --- /dev/null +++ b/tests/integration/PostScopedRoutingTest.php @@ -0,0 +1,121 @@ +query( "DROP TABLE IF EXISTS `{$table}`" ); + $wpdb->query( + "CREATE TABLE `{$table}` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT, + `post_id` BIGINT(20) NOT NULL, + `shared_key` VARCHAR(255) DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_entity` (`post_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + } + } + + public static function tearDownAfterClass(): void { + global $wpdb; + foreach ( array( self::LISTING_TABLE, self::VENDOR_TABLE ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + WPDO_Entity_Registry::init(); + $ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); + $ref->setAccessible( true ); + $ref->setValue( null, null ); + } + + protected function setUp(): void { + global $wpdb; + foreach ( array( self::LISTING_TABLE, self::VENDOR_TABLE ) as $table ) { + $wpdb->query( "TRUNCATE TABLE `{$table}`" ); + } + $GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '1' ); + $GLOBALS['_wp_cache'] = array(); + $GLOBALS['_wp_post_types'] = array( 101 => 'hp_listing', 202 => 'hp_vendor', 303 => 'post' ); + $GLOBALS['_wp_current_filter'] = 'update_post_metadata'; + + $tec = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); + $tec->setAccessible( true ); + $tec->setValue( null, array( self::LISTING_TABLE => true, self::VENDOR_TABLE => true ) ); + $l1 = new ReflectionProperty( WPDO_Cache_Orchestrator::class, 'l1_cache' ); + $l1->setAccessible( true ); + $l1->setValue( null, array() ); + $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); + $mode->setAccessible( true ); + $mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); + + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Entity_Registry::register_group( 'post', 'scope_listing', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_listing' ) ) ); + WPDO_Entity_Registry::register_group( 'post', 'scope_vendor', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) ); + } + + private function value_in( string $table, int $post_id ): ?string { + global $wpdb; + return $wpdb->get_var( $wpdb->prepare( "SELECT shared_key FROM `{$table}` WHERE post_id = %d", $post_id ) ); + } + + public function test_each_post_type_writes_to_its_own_group_table(): void { + WPDO_Hook_Bus::intercept_update( null, 101, 'shared_key', 'listing value', '' ); + WPDO_Hook_Bus::intercept_update( null, 202, 'shared_key', 'vendor value', '' ); + + $this->assertSame( 'listing value', $this->value_in( self::LISTING_TABLE, 101 ) ); + $this->assertNull( $this->value_in( self::VENDOR_TABLE, 101 ) ); + $this->assertSame( 'vendor value', $this->value_in( self::VENDOR_TABLE, 202 ) ); + } + + public function test_reads_come_from_the_post_types_group_table(): void { + global $wpdb; + $wpdb->insert( self::LISTING_TABLE, array( 'post_id' => 101, 'shared_key' => 'from listing table' ) ); + $wpdb->insert( self::VENDOR_TABLE, array( 'post_id' => 101, 'shared_key' => 'stray copy' ) ); + + $this->assertSame( array( 'from listing table' ), WPDO_Hook_Bus::intercept_get( null, 101, 'shared_key', true, 'post' ) ); + } + + public function test_other_post_types_fall_through_to_native_meta(): void { + $this->assertNull( WPDO_Hook_Bus::intercept_update( null, 303, 'shared_key', 'x', '' ) ); + $this->assertNull( WPDO_Hook_Bus::intercept_get( null, 303, 'shared_key', true, 'post' ) ); + } + + public function test_without_the_option_routing_stays_legacy(): void { + $GLOBALS['_wp_options'][ WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ] = '0'; + + WPDO_Hook_Bus::intercept_update( null, 101, 'shared_key', 'legacy', '' ); + + $this->assertSame( 'legacy', $this->value_in( self::VENDOR_TABLE, 101 ) ); + $this->assertNull( $this->value_in( self::LISTING_TABLE, 101 ) ); + } +} diff --git a/tests/unit/EntityRegistryScopeTest.php b/tests/unit/EntityRegistryScopeTest.php new file mode 100644 index 0000000..dfbb4fa --- /dev/null +++ b/tests/unit/EntityRegistryScopeTest.php @@ -0,0 +1,102 @@ +createMock( TMDO_Entity_Adapter_Interface::class ) ); + $GLOBALS['_wp_options'] = array( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION => '1' ); + + TMDO_Entity_Registry::register_group( 'post', 'site_core', array( array( 'key' => '_edit_lock', 'type' => 'text' ) ) ); + TMDO_Entity_Registry::register_group( + 'post', + 'listing_core', + array( + array( 'key' => 'rating', 'type' => 'decimal' ), + array( 'key' => 'price', 'type' => 'decimal' ), + ), + array( 'post_types' => array( 'hp_listing' ) ) + ); + TMDO_Entity_Registry::register_group( + 'post', + 'vendor_core', + array( array( 'key' => 'rating', 'type' => 'decimal' ) ), + array( 'post_types' => array( 'hp_vendor' ) ) + ); + } + + public function test_shared_key_resolves_to_the_group_of_the_post_type(): void { + $this->assertSame( 'listing_core', TMDO_Entity_Registry::get_field( 'post', 'rating', 'hp_listing' )['group'] ); + $this->assertSame( 'vendor_core', TMDO_Entity_Registry::get_field( 'post', 'rating', 'hp_vendor' )['group'] ); + } + + public function test_scoped_key_is_unmanaged_for_other_post_types(): void { + $this->assertNull( TMDO_Entity_Registry::get_field( 'post', 'rating', 'post' ) ); + $this->assertNull( TMDO_Entity_Registry::get_field( 'post', 'price', 'hp_vendor' ) ); + } + + public function test_unscoped_group_applies_to_every_post_type(): void { + $this->assertSame( 'site_core', TMDO_Entity_Registry::get_field( 'post', '_edit_lock', 'hp_listing' )['group'] ); + $this->assertSame( 'site_core', TMDO_Entity_Registry::get_field( 'post', '_edit_lock', 'page' )['group'] ); + } + + public function test_lookup_without_post_type_keeps_legacy_last_wins(): void { + $this->assertSame( 'vendor_core', TMDO_Entity_Registry::get_field( 'post', 'rating' )['group'] ); + } + + public function test_scoping_is_off_until_the_option_enables_it(): void { + $GLOBALS['_wp_options'][ TMDO_Entity_Registry::SCOPED_ROUTING_OPTION ] = '0'; + + $this->assertSame( 'vendor_core', TMDO_Entity_Registry::get_field( 'post', 'rating', 'hp_listing' )['group'] ); + $this->assertSame( 'listing_core', TMDO_Entity_Registry::get_field( 'post', 'price', 'hp_vendor' )['group'] ); + } + + public function test_scope_is_not_part_of_the_field_definition(): void { + // Keeps schema hashes and PR-017 collision checks unchanged. + $fields = TMDO_Entity_Registry::get_group_fields( 'post', 'vendor_core' ); + $this->assertArrayNotHasKey( 'post_types', $fields[0] ); + } + + public function test_scoped_routing_default_waits_for_reconcile_on_sites_with_post_flat_data(): void { + // Existing flat rows may sit in the wrong group table (legacy routing); + // switching lookups before they are reconciled would hide them. + $this->assertSame( '0', TMDO_Installer::post_scoped_routing_default( 'aeav_only', true ) ); + $this->assertSame( '0', TMDO_Installer::post_scoped_routing_default( 'dual_write', true ) ); + $this->assertSame( '1', TMDO_Installer::post_scoped_routing_default( 'aeav_only', false ) ); + $this->assertSame( '1', TMDO_Installer::post_scoped_routing_default( 'disabled', true ) ); + } + + public function test_core_hivepress_groups_are_scoped_without_changing_their_hash(): void { + TMDO_Entity_Registry::init(); + TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); + TMDO_Post_Fields::register_entity_fields(); + + $persisted = array( + 'hp_listing_core' => 'fb5ab760', + 'hp_request_core' => '27d6f4f5', + 'hp_vendor_core' => '5366e5c8', + ); + foreach ( $persisted as $group => $hash ) { + $fields = TMDO_Entity_Registry::get_group_fields( 'post', $group ); + $this->assertSame( $hash, substr( TMDO_Schema_Manager::calculate_schema_hash( $fields ), 0, 8 ), $group ); + } + + $this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_rating', 'hp_listing' )['group'] ); + $this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_status', 'hp_listing' )['group'] ); + $this->assertSame( 'hp_request_core', TMDO_Entity_Registry::get_field( 'post', 'hp_status', 'hp_request' )['group'] ); + $this->assertSame( 'hp_vendor_core', TMDO_Entity_Registry::get_field( 'post', 'hp_user', 'hp_vendor' )['group'] ); + $this->assertSame( 'hp_request_core', TMDO_Entity_Registry::get_field( 'post', 'hp_user', 'hp_request' )['group'] ); + } +}