diff --git a/cli/class-tmdo-cli-v2.php b/cli/class-tmdo-cli-v2.php index 40e17e5..7a374cc 100644 --- a/cli/class-tmdo-cli-v2.php +++ b/cli/class-tmdo-cli-v2.php @@ -170,18 +170,31 @@ class TMDO_CLI_V2 { * [--demote] * : After a complete run, switch the entity to dual_write (both copies kept in step). * + * [--group=] + * : Only this group. + * + * [--retire] + * : With --group, after the write-back: forget the group (registry_meta row) + * and rename its flat table to _retired_. Refused while a plugin + * still registers the group or native meta differs from the flat table. + * * ## EXAMPLES * * wp tmdo bridge-restore-native user --dry-run * wp tmdo bridge-restore-native user --demote + * wp tmdo bridge-restore-native user --group=sso --retire */ public function bridge_restore_native( $args, $assoc_args ): void { $entity = (string) ( $args[0] ?? '' ); if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) { - WP_CLI::error( 'Usage: wp tmdo bridge-restore-native [--dry-run] [--batch=] [--demote]' ); + WP_CLI::error( 'Usage: wp tmdo bridge-restore-native [--dry-run] [--batch=] [--demote] [--group= [--retire]]' ); } $dry_run = isset( $assoc_args['dry-run'] ); - $report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ) ); + $group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) ); + if ( isset( $assoc_args['retire'] ) && '' === $group ) { + WP_CLI::error( '--retire needs --group=.' ); + } + $report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group ); foreach ( $report['groups'] as $group => $g ) { WP_CLI::log( sprintf( '%s/%s: %d row(s)%s', $entity, $group, $g['rows'], $dry_run ? '' : sprintf( ', %d native value(s) written', $g['written'] ) ) ); @@ -190,6 +203,14 @@ class TMDO_CLI_V2 { WP_CLI::success( '[dry-run] Nothing written.' ); return; } + if ( isset( $assoc_args['retire'] ) ) { + $retired = TMDO_Native_Restorer::retire( $entity, $group ); + if ( 'retired' !== $retired['status'] ) { + WP_CLI::error( sprintf( 'Not retired (%s%s). Written values stay in native meta.', $retired['status'], $retired['mismatches'] ? ", {$retired['mismatches']} value(s) differ" : '' ) ); + } + WP_CLI::success( "{$entity}/{$group} retired; flat table kept as {$retired['archived_table']}." ); + return; + } WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' ); } diff --git a/includes/migration/class-tmdo-native-restorer.php b/includes/migration/class-tmdo-native-restorer.php index 25e729e..4bd8c1d 100644 --- a/includes/migration/class-tmdo-native-restorer.php +++ b/includes/migration/class-tmdo-native-restorer.php @@ -20,6 +20,10 @@ * $demote the entity then moves to dual_write, which keeps both copies in * step from then on. * + * A single group can be written back on its own ($group) and then retired: + * retire() forgets its registry_meta row and keeps the flat table under a + * `_retired_` name, so orphan restore never routes its keys again. + * * @package WP_Data_Optimizer */ @@ -47,9 +51,10 @@ final class TMDO_Native_Restorer { * @param bool $dry_run Count only; write nothing. * @param int $batch Rows per batch. * @param bool $demote After a complete run, switch the entity to dual_write. + * @param string $group Only this group ('' = every group). * @return array{groups: array, demoted: bool} */ - public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false ): array { + public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '' ): array { global $wpdb; $report = array( @@ -73,6 +78,9 @@ final class TMDO_Native_Restorer { $cursor = is_array( $cursor ) ? $cursor : array(); $tables = self::group_tables( $entity_type ); + if ( '' !== $group ) { + $tables = array_intersect_key( $tables, array( $group => true ) ); + } foreach ( $tables as $group => $table ) { $entry = array( 'rows' => 0, @@ -121,6 +129,74 @@ final class TMDO_Native_Restorer { return $report; } + /** + * Forget a group that no plugin registers any more, once native meta holds + * every non-NULL value of its flat table (run() with $group first). + * + * @param string $entity_type Entity type. + * @param string $group Group name. + * @return array{status:string, mismatches:int, archived_table:string} + * status: retired | registered (a plugin still registers it) | + * missing (no registry_meta row or flat table) | mismatch (native differs). + */ + public static function retire( string $entity_type, string $group ): array { + global $wpdb; + $result = array( + 'status' => 'missing', + 'mismatches' => 0, + 'archived_table' => '', + ); + $restored = array_map( static fn( $r ) => $r['entity_type'] . '/' . $r['group'], TMDO_Entity_Registry::get_restored_groups() ); + if ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) && ! in_array( "{$entity_type}/{$group}", $restored, true ) ) { + $result['status'] = 'registered'; + return $result; + } + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + $meta_table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta'; + $table = TMDO_Schema_Manager::get_table_name( $entity_type, $group ); + $defs = $wpdb->get_var( $wpdb->prepare( "SELECT field_definitions FROM `{$meta_table}` WHERE entity_type = %s AND group_name = %s", $entity_type, $group ) ); + $fields = json_decode( (string) $defs, true ); + if ( ! $adapter || ! is_array( $fields ) || null === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) { + return $result; + } + + $native_table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + foreach ( (array) $wpdb->get_results( "SELECT * FROM `{$table}`", ARRAY_A ) as $row ) { + foreach ( $fields as $field ) { + $col = TMDO_Schema_Manager::sanitize_column_name( (string) ( $field['key'] ?? '' ) ); + if ( '' === $col || ! isset( $row[ $col ] ) ) { + continue; + } + $want = (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) ); + $native = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$native_table}` WHERE `{$id_col}` = %d AND meta_key = %s", (int) $row[ $id_col ], $field['key'] ) ); + if ( array( $want ) !== $native ) { + ++$result['mismatches']; + } + } + } + if ( $result['mismatches'] ) { + $result['status'] = 'mismatch'; + return $result; + } + + $archived = $table . '_retired_' . gmdate( 'Ymd' ); + $wpdb->query( "RENAME TABLE `{$table}` TO `{$archived}`" ); + $wpdb->delete( + $meta_table, + array( + 'entity_type' => $entity_type, + 'group_name' => $group, + ) + ); + delete_transient( 'tmdo_orphan_native_keys' ); + TMDO_Schema_Manager::flush_table_exists_cache(); + + $result['status'] = 'retired'; + $result['archived_table'] = $archived; + return $result; + } + /** * Registered groups of the entity whose flat table exists. * diff --git a/tests/integration/NativeRestorerRetireTest.php b/tests/integration/NativeRestorerRetireTest.php new file mode 100644 index 0000000..4b88f10 --- /dev/null +++ b/tests/integration/NativeRestorerRetireTest.php @@ -0,0 +1,146 @@ +query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::A . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, token VARCHAR(255) DEFAULT NULL, ips LONGTEXT 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' ); + $wpdb->query( 'CREATE TABLE `' . self::B . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, other 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' ); + $wpdb->query( 'INSERT INTO `' . self::A . "` (user_id, token, ips) VALUES (1, 't1', '[\"1.1.1.1\"]'), (2, 't2', NULL)" ); + $wpdb->insert( self::B, array( 'user_id' => 1, 'other' => 'o1' ) ); + $wpdb->query( 'UPDATE `' . self::A . "` SET updated_at = '2020-01-01 00:00:00'" ); + $wpdb->query( 'UPDATE `' . self::B . "` SET updated_at = '2020-01-01 00:00:00'" ); + + $GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'aeav_only' ) ); + $GLOBALS['_wp_cache'] = array(); + $GLOBALS['_wp_transients'] = array(); + TMDO_Mode_Manager::reset_cache(); + + self::register( true ); + $wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) ); + $wpdb->insert( + self::META, + array( + 'entity_type' => 'user', + 'group_name' => 'rt_a', + 'schema_hash' => 'itest', + 'field_definitions' => wp_json_encode( WPDO_Entity_Registry::get_group_fields( 'user', 'rt_a' ) ), + ) + ); + + $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); + $ref->setAccessible( true ); + $ref->setValue( null, array() ); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + TMDO_Mode_Manager::reset_cache(); + } + + /** Fresh registry; rt_b always registered, rt_a only while its provider is active. */ + private static function register( bool $with_a ): void { + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + if ( $with_a ) { + WPDO_Entity_Registry::register_group( + 'user', + 'rt_a', + array( + array( 'key' => 'token', 'type' => 'text' ), + array( 'key' => 'ips', 'type' => 'textarea' ), + ) + ); + } + WPDO_Entity_Registry::register_group( 'user', 'rt_b', array( array( 'key' => 'other', 'type' => 'text' ) ) ); + } + + private static function drop(): void { + global $wpdb; + foreach ( (array) $wpdb->get_col( "SHOW TABLES LIKE 'wp\\_itest\\_wpdo\\_user\\_rt\\_%'" ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" ); + $wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) ); + } + + private function native( int $user_id, string $key ): array { + global $wpdb; + return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$wpdb->usermeta}` WHERE user_id = %d AND meta_key = %s", $user_id, $key ) ); + } + + public function test_group_option_writes_back_only_that_group(): void { + $report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' ); + + $this->assertSame( array( 'rt_a' ), array_keys( $report['groups'] ) ); + $this->assertSame( array( 't1' ), $this->native( 1, 'token' ) ); + $this->assertSame( array( '["1.1.1.1"]' ), $this->native( 1, 'ips' ) ); + $this->assertSame( array(), $this->native( 1, 'other' ), 'rt_b untouched' ); + } + + public function test_retire_refuses_a_group_that_is_still_registered(): void { + TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' ); + + $this->assertSame( 'registered', TMDO_Native_Restorer::retire( 'user', 'rt_a' )['status'] ); + global $wpdb; + $this->assertSame( self::A, $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) ); + } + + public function test_retire_refuses_when_native_meta_differs_from_the_flat_table(): void { + self::register( false ); + + $result = TMDO_Native_Restorer::retire( 'user', 'rt_a' ); + + $this->assertSame( 'mismatch', $result['status'] ); + $this->assertSame( 3, $result['mismatches'], 't1, ["1.1.1.1"], t2 are not native yet' ); + } + + public function test_retire_forgets_the_group_and_keeps_its_table_as_a_backup(): void { + global $wpdb; + TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' ); + self::register( false ); + + $result = TMDO_Native_Restorer::retire( 'user', 'rt_a' ); + + $this->assertSame( 'retired', $result['status'] ); + $this->assertMatchesRegularExpression( '/^' . self::A . '_retired_\d{8}$/', $result['archived_table'] ); + $this->assertSame( $result['archived_table'], $wpdb->get_var( "SHOW TABLES LIKE '" . $result['archived_table'] . "'" ) ); + $this->assertNull( $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) ); + $this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::META . "` WHERE entity_type = 'user' AND group_name = 'rt_a'" ) ); + $this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups(), 'orphan restore no longer routes the keys to the flat table' ); + } +}