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
@@ -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',
)
);
}
}
+116
View File
@@ -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'] );
}
}