feat(2meet-data-optimizer): guard orphaned flat-table groups

- 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
This commit is contained in:
2026-09-25 23:38:12 +08:00
parent e9d8696c93
commit 1653011f4f
9 changed files with 583 additions and 20 deletions
+98 -16
View File
@@ -34,6 +34,9 @@ final class TMDO_Entity_Registry {
/** @var array<string, array> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
private static array $collisions = array();
/** @var array<string, 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<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。
*
@@ -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
*/