hardening 2.0: 2meet-data-optimizer (staged rollout, dev22 first) #3
@@ -2990,7 +2990,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
||||
?>
|
||||
<h2><?php esc_html_e( '健康檢查(Doctor)', '2meet-data-optimizer' ); ?></h2>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '8 項自我診斷檢查:schema 完整性、錯誤預算、hook 衝突、autoload 大小、postmeta 爆量、orphan zone rows、缺少快照、實體群組名稱衝突。', '2meet-data-optimizer' ); ?>
|
||||
<?php esc_html_e( '9 項自我診斷檢查:schema 完整性、錯誤預算、hook 衝突、autoload 大小、postmeta 爆量、orphan zone rows、缺少快照、實體群組名稱衝突、孤兒實體群組。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<a class="button" href="<?php echo esc_url( admin_url( 'site-health.php' ) ); ?>">
|
||||
@@ -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' ) ),
|
||||
);
|
||||
?>
|
||||
<table class="widefat striped">
|
||||
|
||||
@@ -184,7 +184,7 @@ class TMDO_Help_Tabs {
|
||||
* @return string
|
||||
*/
|
||||
private static function content_doctor(): string {
|
||||
return '<p>' . esc_html__( '8 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
|
||||
return '<p>' . esc_html__( '9 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ol class="wpdo-list-disc">'
|
||||
. '<li><strong>schema_drift</strong> — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>error_budget</strong> — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '</li>'
|
||||
@@ -194,6 +194,7 @@ class TMDO_Help_Tabs {
|
||||
. '<li><strong>orphan_zone_rows</strong> — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>missing_snapshot</strong> — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>entity_group_collisions</strong> — ' . esc_html__( '同名實體群組被不同外掛以不同欄位註冊(後者被忽略)', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>orphaned_entity_groups</strong> — ' . esc_html__( '扁平表還在但已無外掛註冊的群組(已依保存的定義還原以維持可讀)', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ol>'
|
||||
. '<p>' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
@@ -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 ) ) {
|
||||
|
||||
@@ -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 ) ) {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Orphaned flat-table groups stay readable.
|
||||
*
|
||||
* Scenario: a plugin registered an entity group, its flat table was created
|
||||
* (definition persisted in wpdo_registry_meta), data landed there, and then
|
||||
* the plugin went away. In aeav_only mode the values exist only in the flat
|
||||
* table, so without the group registration reads come back empty.
|
||||
*/
|
||||
class OrphanGroupRestoreTest extends TestCase {
|
||||
|
||||
private const GROUP = 'orphan_itest';
|
||||
private const TABLE = 'wp_itest_wpdo_user_orphan_itest';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-type-caster.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-mode-manager.php',
|
||||
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
'includes/engine/class-tmdo-hook-bus.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
WPDO_Installer::install_v2_tables();
|
||||
|
||||
global $wpdb;
|
||||
$wpdb->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',
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Restoring orphaned entity groups from their persisted definition.
|
||||
*
|
||||
* When the plugin that registered a group is gone, its flat table keeps the
|
||||
* data but nothing maps the meta keys to it any more — in aeav_only mode the
|
||||
* values read back as empty. restore_group() re-registers the stored
|
||||
* definition for read/write routing only: it must never queue DDL and must
|
||||
* not take over keys another live group already manages.
|
||||
*/
|
||||
class EntityRegistryRestoreTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
if ( ! class_exists( 'TMDO_Site_Health' ) ) {
|
||||
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-site-health.php';
|
||||
}
|
||||
TMDO_Entity_Registry::init();
|
||||
TMDO_Entity_Registry::register_adapter( 'user', $this->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'] );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user