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:
@@ -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