From 1653011f4ff8b955630354e901f44046e321a63f Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 23:38:12 +0800 Subject: [PATCH] feat(2meet-data-optimizer): guard orphaned flat-table groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - On init:1, re-register aeav_only groups whose flat table exists and whose definition is in wpdo_registry_meta but no active plugin registers them, so their data stays readable (routing only, never DDL) - Keys owned by a live group, or with rows in native meta (newer data written while orphaned), are left alone; the native-meta check is cached for a day - Surface restored groups via Site Health (wpdo_orphaned_entity_groups), the admin Doctor tab, and wp wpdo doctor - Verify: EntityRegistryRestoreTest (9), OrphanGroupRestoreTest (7); core unit 622 / integration 439, HP 141/49, WC 10/22 OK; dry-run on dev20–22 restores 16/17/4 groups, keeps hp_taxonomy hp_sort_order/hp_icon on native - PR: PR-018 --- admin/class-tmdo-admin.php | 3 +- admin/class-tmdo-help-tabs.php | 3 +- cli/class-tmdo-cli.php | 15 ++ includes/class-tmdo-core.php | 6 + .../diagnostic/class-tmdo-site-health.php | 47 ++++- .../engine/class-tmdo-entity-registry.php | 114 +++++++++-- includes/engine/class-tmdo-schema-manager.php | 107 ++++++++++ tests/integration/OrphanGroupRestoreTest.php | 192 ++++++++++++++++++ tests/unit/EntityRegistryRestoreTest.php | 116 +++++++++++ 9 files changed, 583 insertions(+), 20 deletions(-) create mode 100644 tests/integration/OrphanGroupRestoreTest.php create mode 100644 tests/unit/EntityRegistryRestoreTest.php diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php index c3afe3a..ecc67bf 100644 --- a/admin/class-tmdo-admin.php +++ b/admin/class-tmdo-admin.php @@ -2990,7 +2990,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' ?>

- +

@@ -3012,6 +3012,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' 'wpdo_orphan_zone_rows' => array( 'check_orphan_zone_rows', __( 'Orphan zone rows', '2meet-data-optimizer' ) ), 'wpdo_missing_snapshot' => array( 'check_missing_snapshot', __( '缺少最近快照', '2meet-data-optimizer' ) ), 'wpdo_entity_group_collisions' => array( 'check_entity_group_collisions', __( '實體群組名稱衝突', '2meet-data-optimizer' ) ), + 'wpdo_orphaned_entity_groups' => array( 'check_orphaned_entity_groups', __( '孤兒實體群組', '2meet-data-optimizer' ) ), ); ?> diff --git a/admin/class-tmdo-help-tabs.php b/admin/class-tmdo-help-tabs.php index b4899a4..8aa3066 100644 --- a/admin/class-tmdo-help-tabs.php +++ b/admin/class-tmdo-help-tabs.php @@ -184,7 +184,7 @@ class TMDO_Help_Tabs { * @return string */ private static function content_doctor(): string { - return '

' . esc_html__( '8 項自動健康檢查:', '2meet-data-optimizer' ) . '

' + return '

' . esc_html__( '9 項自動健康檢查:', '2meet-data-optimizer' ) . '

' . '
    ' . '
  1. schema_drift — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '
  2. ' . '
  3. error_budget — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '
  4. ' @@ -194,6 +194,7 @@ class TMDO_Help_Tabs { . '
  5. orphan_zone_rows — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '
  6. ' . '
  7. missing_snapshot — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '
  8. ' . '
  9. entity_group_collisions — ' . esc_html__( '同名實體群組被不同外掛以不同欄位註冊(後者被忽略)', '2meet-data-optimizer' ) . '
  10. ' + . '
  11. orphaned_entity_groups — ' . esc_html__( '扁平表還在但已無外掛註冊的群組(已依保存的定義還原以維持可讀)', '2meet-data-optimizer' ) . '
  12. ' . '
' . '

' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '

'; } diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php index 5c0023c..da7b033 100644 --- a/cli/class-tmdo-cli.php +++ b/cli/class-tmdo-cli.php @@ -349,6 +349,21 @@ class TMDO_CLI { $all_ok = false; } + // ── Orphaned entity groups (restored from wpdo_registry_meta) ─────────── + foreach ( TMDO_Entity_Registry::get_restored_groups() as $r ) { + WP_CLI::warning( + sprintf( + ' [ORPHAN] %s/%s — no active plugin registers it; restored %d field(s) from wpdo_registry_meta%s%s. Reactivate the owner or write the data back before removing it.', + $r['entity_type'], + $r['group'], + $r['fields'], + $r['skipped'] ? ' (skipped, owned by another group: ' . implode( ', ', $r['skipped'] ) . ')' : '', + $r['native'] ? ' (left on native meta, which has newer data: ' . implode( ', ', $r['native'] ) . ')' : '' + ) + ); + $all_ok = false; + } + // Check recent errors. $errors = TMDO_Logger::get_recent( '', 5 ); if ( ! empty( $errors ) ) { diff --git a/includes/class-tmdo-core.php b/includes/class-tmdo-core.php index 21a8846..4a4a459 100644 --- a/includes/class-tmdo-core.php +++ b/includes/class-tmdo-core.php @@ -71,6 +71,12 @@ class TMDO_Core { if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) { TMDO_Custom_Table_Registry::instance()->fire_registration(); } + // Every provider has registered by now: re-register groups whose flat + // table still holds data but whose plugin is gone, so aeav_only + // values keep reading back (routing only — never DDL). + if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) { + TMDO_Schema_Manager::restore_orphaned_groups(); + } if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) { $pending = TMDO_Entity_Registry::get_pending_schemas(); if ( ! empty( $pending ) ) { diff --git a/includes/diagnostic/class-tmdo-site-health.php b/includes/diagnostic/class-tmdo-site-health.php index bba38c2..e2a8722 100644 --- a/includes/diagnostic/class-tmdo-site-health.php +++ b/includes/diagnostic/class-tmdo-site-health.php @@ -2,7 +2,7 @@ /** * TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3). * - * Registers 8 tests under Tools → Site Health → Status: + * Registers 9 tests under Tools → Site Health → Status: * 1. wpdo_schema_drift (critical) * 2. wpdo_error_budget (recommended) * 3. wpdo_hook_conflicts (recommended) @@ -11,6 +11,7 @@ * 6. wpdo_orphan_zone_rows (recommended) * 7. wpdo_missing_snapshot (critical) * 8. wpdo_entity_group_collisions (recommended) + * 9. wpdo_orphaned_entity_groups (recommended) * * Each test result is cached for 5 minutes to keep Site Health responsive. * @@ -43,6 +44,7 @@ class TMDO_Site_Health { 'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows', 'wpdo_missing_snapshot' => 'check_missing_snapshot', 'wpdo_entity_group_collisions' => 'check_entity_group_collisions', + 'wpdo_orphaned_entity_groups' => 'check_orphaned_entity_groups', ); /** @@ -55,7 +57,7 @@ class TMDO_Site_Health { } /** - * Register the 8 tests with WP Site Health. + * Register the 9 tests with WP Site Health. * * @param array $tests Existing tests. * @return array @@ -86,6 +88,7 @@ class TMDO_Site_Health { 'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ), 'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ), 'wpdo_entity_group_collisions' => __( 'WPDO entity group collisions', '2meet-data-optimizer' ), + 'wpdo_orphaned_entity_groups' => __( 'WPDO orphaned entity groups', '2meet-data-optimizer' ), ); return $map[ $key ] ?? $key; } @@ -468,6 +471,46 @@ class TMDO_Site_Health { ); } + // ─── Test 9: Orphaned entity groups ───────────────────────────────── + + /** + * Report flat-table groups kept alive from wpdo_registry_meta because no + * active plugin registers them any more. + * + * Not cached for the same reason as check_entity_group_collisions(). + * + * @return array Site Health result. + */ + public static function check_orphaned_entity_groups(): array { + $restored = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_restored_groups() : array(); + if ( empty( $restored ) ) { + return self::wrap( 'wpdo_orphaned_entity_groups', self::pass( __( 'Every flat-table group is registered by an active plugin.', '2meet-data-optimizer' ) ) ); + } + $lines = array(); + foreach ( $restored as $r ) { + $lines[] = sprintf( + '%s/%s (%d fields%s%s)', + $r['entity_type'], + $r['group'], + $r['fields'], + $r['skipped'] ? ', skipped: ' . implode( ',', $r['skipped'] ) : '', + $r['native'] ? ', left on native meta (newer data there): ' . implode( ',', $r['native'] ) : '' + ); + } + return self::wrap( + 'wpdo_orphaned_entity_groups', + self::fail( + __( 'WPDO entity groups restored from stored definitions', '2meet-data-optimizer' ), + sprintf( + /* translators: %s: list of entity/group names */ + __( 'No active plugin registers these groups, so their stored definitions were re-applied to keep the flat-table data readable. Reactivate the owning plugin, or write the data back to native meta before removing it: %s', '2meet-data-optimizer' ), + implode( '; ', $lines ) + ), + 'recommended' + ) + ); + } + /** * Wrap a raw check result with required Site Health envelope fields. * diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php index c6e6ec1..d355b46 100644 --- a/includes/engine/class-tmdo-entity-registry.php +++ b/includes/engine/class-tmdo-entity-registry.php @@ -34,6 +34,9 @@ final class TMDO_Entity_Registry { /** @var array "entity/group" → 同名群組以不同定義重複註冊的紀錄 */ private static array $collisions = array(); + /** @var array "entity/group" → 從 wpdo_registry_meta 還原的孤兒群組 */ + private static array $restored = array(); + /** 合法的欄位型別 */ public const VALID_TYPES = array( 'text', @@ -58,6 +61,7 @@ final class TMDO_Entity_Registry { self::$field_index = array(); self::$pending_schemas = array(); self::$collisions = array(); + self::$restored = array(); } // ───────────────────────────────────────────────────────── @@ -109,7 +113,41 @@ final class TMDO_Entity_Registry { 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; + } + + // 建立索引以供快速查詢 + foreach ( $normalized as $normalized_field ) { + self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field; + } + + self::$groups[ $entity_type ][ $group_name ] = $normalized; + + // 加入待建表佇列 + self::$pending_schemas[] = array( + 'type' => $entity_type, + 'group' => $group_name, + 'fields' => $normalized, + ); + + return true; + } + + /** + * 驗證並正規化欄位定義(略過缺 key/type 或型別不合法者)。 + */ + private static function normalize_fields( string $entity_type, string $group_name, array $fields ): array { $normalized = array(); foreach ( $fields as $field ) { @@ -142,35 +180,79 @@ final class TMDO_Entity_Registry { $normalized[] = $normalized_field; } - if ( empty( $normalized ) ) { + 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 + */ + 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; } - // 防重複登錄:相同定義(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 ); + $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 ( $normalized as $normalized_field ) { - self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field; + foreach ( $kept as $field ) { + self::$field_index[ $entity_type ][ $field['key'] ] = $field; } + self::$groups[ $entity_type ][ $group_name ] = $kept; - self::$groups[ $entity_type ][ $group_name ] = $normalized; - - // 加入待建表佇列 - self::$pending_schemas[] = array( - 'type' => $entity_type, - 'group' => $group_name, - 'fields' => $normalized, + self::$restored[ $id ] = array( + 'entity_type' => $entity_type, + 'group' => $group_name, + 'fields' => count( $kept ), + 'skipped' => $skipped, + 'native' => $native, ); return true; } + /** + * 本請求從 wpdo_registry_meta 還原的孤兒群組。 + * + * @return array + */ + public static function get_restored_groups(): array { + return array_values( self::$restored ); + } + /** * 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。 * diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php index 9188165..bd2fc78 100644 --- a/includes/engine/class-tmdo-schema-manager.php +++ b/includes/engine/class-tmdo-schema-manager.php @@ -268,6 +268,113 @@ final class TMDO_Schema_Manager { ); } + /** + * 還原孤兒群組:扁平表還在、定義存在 wpdo_registry_meta,但已沒有外掛註冊。 + * + * 在所有註冊都完成後(init:1 晚綁定)呼叫。只處理 aeav_only 的實體:其他 + * 模式仍會寫原生 meta,孤兒 key 從原生讀取即正確;全部非 aeav_only 時不查資料庫。 + * 原生 meta 已有資料的 key(孤兒期間寫入,比扁平表新)留在原生,不還原。 + * 還原只影響讀寫路由,不會建立或修改任何資料表。 + * + * @return int 還原的群組數 + */ + public static function restore_orphaned_groups(): int { + global $wpdb; + + if ( ! class_exists( 'TMDO_Mode_Manager' ) ) { + return 0; + } + $types = array_values( + array_filter( + TMDO_Entity_Registry::VALID_ENTITY_TYPES, + static fn( $type ) => TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $type ) + ) + ); + if ( empty( $types ) ) { + return 0; + } + + $meta_table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta'; + $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) ); + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT entity_type, group_name, field_definitions FROM `{$meta_table}` WHERE entity_type IN ({$placeholders})", ...$types ), + ARRAY_A + ); + + $orphans = array(); + foreach ( (array) $rows as $row ) { + if ( ! TMDO_Entity_Registry::get_group_fields( $row['entity_type'], $row['group_name'] ) ) { + $orphans[] = $row; + } + } + if ( empty( $orphans ) ) { + return 0; + } + + // One SHOW TABLES for all candidates instead of one per orphan. + $existing = (array) $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . TMDO_TABLE_PREFIX ) . '%' ) ); + + // "entity:key" → native meta has rows. Stable while the key is restored + // (aeav_only writes go to the flat table only), so a day-long cache is safe. + $native_cache = get_transient( 'tmdo_orphan_native_keys' ); + $native_cache = is_array( $native_cache ) ? $native_cache : array(); + $cache_dirty = false; + + $restored = 0; + foreach ( $orphans as $row ) { + if ( ! in_array( self::get_table_name( $row['entity_type'], $row['group_name'] ), $existing, true ) ) { + continue; + } + $fields = json_decode( (string) $row['field_definitions'], true ); + if ( ! is_array( $fields ) ) { + continue; + } + $native_keys = array(); + foreach ( $fields as $field ) { + $key = (string) ( $field['key'] ?? '' ); + if ( '' === $key ) { + continue; + } + $cache_key = $row['entity_type'] . ':' . $key; + if ( ! array_key_exists( $cache_key, $native_cache ) ) { + $native_cache[ $cache_key ] = self::native_meta_has_key( $row['entity_type'], $key ); + $cache_dirty = true; + } + if ( $native_cache[ $cache_key ] ) { + $native_keys[] = $key; + } + } + if ( TMDO_Entity_Registry::restore_group( $row['entity_type'], $row['group_name'], $fields, $native_keys ) ) { + ++$restored; + } + } + + if ( $cache_dirty ) { + set_transient( 'tmdo_orphan_native_keys', $native_cache, DAY_IN_SECONDS ); + } + + return $restored; + } + + /** + * Whether the entity's native meta table holds any row for this key. + */ + private static function native_meta_has_key( string $entity_type, string $meta_key ): bool { + global $wpdb; + $tables = array( + 'post' => $wpdb->postmeta, + 'user' => $wpdb->usermeta, + 'term' => $wpdb->termmeta, + 'comment' => $wpdb->commentmeta, + ); + if ( empty( $tables[ $entity_type ] ) ) { + return false; + } + return (bool) $wpdb->get_var( + $wpdb->prepare( "SELECT 1 FROM `{$tables[ $entity_type ]}` WHERE meta_key = %s LIMIT 1", $meta_key ) + ); + } + /** * 取得已儲存的 Schema hash */ diff --git a/tests/integration/OrphanGroupRestoreTest.php b/tests/integration/OrphanGroupRestoreTest.php new file mode 100644 index 0000000..b2416d9 --- /dev/null +++ b/tests/integration/OrphanGroupRestoreTest.php @@ -0,0 +1,192 @@ +query( + "CREATE TABLE IF NOT EXISTS `{$wpdb->usermeta}` ( + umeta_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + meta_key VARCHAR(255) DEFAULT NULL, + meta_value LONGTEXT, + PRIMARY KEY (umeta_id), + KEY meta_key (meta_key(191)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + } + + protected function setUp(): void { + global $wpdb; + self::drop_fixture(); + $GLOBALS['_wp_options'] = array(); + $GLOBALS['_wp_cache'] = array(); + $GLOBALS['_wp_transients'] = array(); + self::reset_statics(); + + // A provider registered the group earlier: its flat table exists and the + // normalized definition was persisted to registry_meta (what + // Schema_Manager::create_or_upgrade_table() leaves behind — built by hand + // here because that method needs the real wp-admin upgrade.php). + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + WPDO_Entity_Registry::register_group( 'user', self::GROUP, array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) ); + $wpdb->query( + 'CREATE TABLE `' . self::TABLE . '` ( + `id` BIGINT(20) NOT NULL AUTO_INCREMENT, + `user_id` BIGINT(20) NOT NULL, + `orphan_note` 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` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + ); + $wpdb->insert( + $wpdb->prefix . 'wpdo_registry_meta', + array( + 'entity_type' => 'user', + 'group_name' => self::GROUP, + 'schema_hash' => 'itest', + 'field_definitions' => wp_json_encode( WPDO_Entity_Registry::get_group_fields( 'user', self::GROUP ) ), + ) + ); + $wpdb->insert( self::TABLE, array( 'user_id' => 77, 'orphan_note' => 'kept' ) ); + + // The provider goes away: next request starts with an empty registry. + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + self::reset_statics(); + } + + protected function tearDown(): void { + self::drop_fixture(); + WPDO_Entity_Registry::init(); + self::set_modes( null ); + } + + public function test_orphaned_group_is_unreadable_before_restore(): void { + self::set_modes( 'aeav_only' ); + + $this->assertNull( WPDO_Hook_Bus::intercept_get( null, 77, 'orphan_note', true, 'user' ) ); + } + + public function test_restore_makes_aeav_only_data_readable_again(): void { + self::set_modes( 'aeav_only' ); + + $this->assertSame( 1, WPDO_Schema_Manager::restore_orphaned_groups() ); + + $this->assertSame( array( 'kept' ), WPDO_Hook_Bus::intercept_get( null, 77, 'orphan_note', true, 'user' ) ); + $this->assertSame( array(), WPDO_Entity_Registry::get_pending_schemas(), 'Restoring must not queue DDL.' ); + } + + public function test_restore_only_applies_to_aeav_only(): void { + // dual_write / shadow_read still write native meta, so an orphaned key + // keeps reading correctly from there — nothing to restore. + foreach ( array( 'disabled', 'dual_write', 'shadow_read' ) as $mode ) { + self::set_modes( $mode ); + $this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups(), $mode ); + $this->assertNull( WPDO_Entity_Registry::get_field( 'user', 'orphan_note' ), $mode ); + } + } + + public function test_key_with_native_meta_rows_stays_on_native(): void { + global $wpdb; + self::set_modes( 'aeav_only' ); + // Written to native meta while the group was unregistered — newer than the flat copy. + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 5, 'meta_key' => 'orphan_note', 'meta_value' => 'native' ) ); + + $this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() ); + + $this->assertNull( WPDO_Hook_Bus::intercept_get( null, 5, 'orphan_note', true, 'user' ), 'Native meta must keep serving this key.' ); + $this->assertSame( array( 'orphan_note' ), WPDO_Entity_Registry::get_restored_groups()[0]['native'] ); + } + + public function test_native_meta_check_is_cached(): void { + self::set_modes( 'aeav_only' ); + WPDO_Schema_Manager::restore_orphaned_groups(); + + $this->assertSame( array( 'user:orphan_note' => false ), get_transient( 'tmdo_orphan_native_keys' ) ); + } + + public function test_restore_skips_groups_whose_table_is_gone(): void { + global $wpdb; + self::set_modes( 'aeav_only' ); + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + self::reset_statics(); + + $this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() ); + } + + public function test_registered_group_is_left_alone(): void { + self::set_modes( 'aeav_only' ); + WPDO_Entity_Registry::register_group( 'user', self::GROUP, array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) ); + + $this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() ); + $this->assertSame( array(), WPDO_Entity_Registry::get_restored_groups() ); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static function drop_fixture(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + $wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => self::GROUP ) ); + $wpdb->delete( $wpdb->usermeta, array( 'meta_key' => 'orphan_note' ) ); + } + + /** Clear Schema_Manager's table_exists cache and the Cache_Orchestrator L1. */ + private static function reset_statics(): void { + $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); + $ref->setAccessible( true ); + $ref->setValue( null, array() ); + + $l1 = new ReflectionProperty( WPDO_Cache_Orchestrator::class, 'l1_cache' ); + $l1->setAccessible( true ); + $l1->setValue( null, array() ); + } + + /** Inject the user mode straight into Mode_Manager's cache (null = reset). */ + private static function set_modes( ?string $user_mode ): void { + $ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); + $ref->setAccessible( true ); + $ref->setValue( + null, + null === $user_mode ? null : array( + 'user' => $user_mode, + 'post' => 'disabled', + 'term' => 'disabled', + 'comment' => 'disabled', + ) + ); + } +} diff --git a/tests/unit/EntityRegistryRestoreTest.php b/tests/unit/EntityRegistryRestoreTest.php new file mode 100644 index 0000000..9f7a77c --- /dev/null +++ b/tests/unit/EntityRegistryRestoreTest.php @@ -0,0 +1,116 @@ +createMock( TMDO_Entity_Adapter_Interface::class ) ); + } + + private function stored_definition(): array { + return array( + array( 'key' => 'orphan_note', 'type' => 'text', 'group' => 'orphan', 'entity_type' => 'user' ), + array( 'key' => 'orphan_score', 'type' => 'integer', 'searchable' => true, 'group' => 'orphan', 'entity_type' => 'user' ), + ); + } + + public function test_restore_registers_fields_for_routing(): void { + $this->assertTrue( TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() ) ); + + $this->assertSame( 'orphan', TMDO_Entity_Registry::get_field( 'user', 'orphan_note' )['group'] ); + $this->assertSame( array( 'orphan_note', 'orphan_score' ), TMDO_Entity_Registry::get_group_keys( 'user', 'orphan' ) ); + } + + public function test_restore_never_queues_ddl(): void { + TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() ); + + $this->assertSame( array(), TMDO_Entity_Registry::get_pending_schemas() ); + } + + public function test_restore_skips_keys_managed_by_a_live_group(): void { + TMDO_Entity_Registry::register_group( 'user', 'live', array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) ); + + TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() ); + + $this->assertSame( 'live', TMDO_Entity_Registry::get_field( 'user', 'orphan_note' )['group'] ); + $this->assertSame( array( 'orphan_score' ), TMDO_Entity_Registry::get_group_keys( 'user', 'orphan' ) ); + $restored = TMDO_Entity_Registry::get_restored_groups(); + $this->assertSame( array( 'orphan_note' ), $restored[0]['skipped'] ); + } + + public function test_restore_leaves_keys_with_native_data_on_native_meta(): void { + TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition(), array( 'orphan_note' ) ); + + $this->assertNull( TMDO_Entity_Registry::get_field( 'user', 'orphan_note' ) ); + $this->assertSame( array( 'orphan_score' ), TMDO_Entity_Registry::get_group_keys( 'user', 'orphan' ) ); + $this->assertSame( array( 'orphan_note' ), TMDO_Entity_Registry::get_restored_groups()[0]['native'] ); + } + + public function test_group_left_entirely_on_native_is_still_reported(): void { + $this->assertFalse( TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition(), array( 'orphan_note', 'orphan_score' ) ) ); + + $this->assertNotContains( 'orphan', TMDO_Entity_Registry::get_groups_for_type( 'user' ) ); + $restored = TMDO_Entity_Registry::get_restored_groups(); + $this->assertSame( 0, $restored[0]['fields'] ); + $this->assertSame( array( 'orphan_note', 'orphan_score' ), $restored[0]['native'] ); + } + + public function test_restore_is_refused_when_group_is_registered(): void { + TMDO_Entity_Registry::register_group( 'user', 'orphan', array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) ); + + $this->assertFalse( TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() ) ); + $this->assertSame( array(), TMDO_Entity_Registry::get_restored_groups() ); + } + + public function test_restore_is_refused_when_every_key_is_taken(): void { + TMDO_Entity_Registry::register_group( + 'user', + 'live', + array( + array( 'key' => 'orphan_note', 'type' => 'text' ), + array( 'key' => 'orphan_score', 'type' => 'integer' ), + ) + ); + + $this->assertFalse( TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() ) ); + $this->assertNotContains( 'orphan', TMDO_Entity_Registry::get_groups_for_type( 'user' ) ); + } + + public function test_restored_groups_are_listed_and_cleared_by_init(): void { + TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() ); + + $restored = TMDO_Entity_Registry::get_restored_groups(); + $this->assertSame( 'user', $restored[0]['entity_type'] ); + $this->assertSame( 'orphan', $restored[0]['group'] ); + $this->assertSame( 2, $restored[0]['fields'] ); + + TMDO_Entity_Registry::init(); + $this->assertSame( array(), TMDO_Entity_Registry::get_restored_groups() ); + } + + public function test_site_health_reports_restored_groups(): void { + $this->assertSame( 'good', TMDO_Site_Health::check_orphaned_entity_groups()['status'] ); + + TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() ); + + $result = TMDO_Site_Health::check_orphaned_entity_groups(); + $this->assertSame( 'recommended', $result['status'] ); + $this->assertStringContainsString( 'user/orphan', $result['description'] ); + $this->assertArrayHasKey( 'wpdo_orphaned_entity_groups', TMDO_Site_Health::register_tests( array() )['direct'] ); + } +}