feat(2meet-data-optimizer): group-scoped native write-back and group retirement
- TMDO_Native_Restorer::run() takes an optional group; the CLI gains `bridge-restore-native <entity> --group=<g>`. - New retire(): once native meta holds every non-NULL value of a group's flat table (checked against the persisted field definitions), forget its registry_meta row and rename the table to <table>_retired_<Ymd>, so orphan restore no longer routes its keys to the flat table. Refused while a plugin still registers the group or any value differs. CLI: `--group=<g> --retire`. - For retiring the user `sso` group (SSO AddOn deprecation). - Verify: core unit 659 / integration 479 green, phpcs clean - PR: PR-042
This commit is contained in:
@@ -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=<group>]
|
||||
* : Only this group.
|
||||
*
|
||||
* [--retire]
|
||||
* : With --group, after the write-back: forget the group (registry_meta row)
|
||||
* and rename its flat table to <table>_retired_<Ymd>. 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 <post|user|term|comment> [--dry-run] [--batch=<n>] [--demote]' );
|
||||
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--batch=<n>] [--demote] [--group=<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=<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.' );
|
||||
}
|
||||
|
||||
|
||||
@@ -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_<Ymd>` 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<string, array{rows:int, written:int}>, 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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Retiring one entity group: write its flat values back to native meta, then
|
||||
* forget it so orphan restore never routes its keys to the flat table again.
|
||||
*
|
||||
* Fixture (user, aeav_only): group rt_a (token, ips) and rt_b (other).
|
||||
* rt_a user 1: token 't1', ips '["1.1.1.1"]' user 2: token 't2', ips NULL
|
||||
* rt_b user 1: other 'o1'
|
||||
*/
|
||||
class NativeRestorerRetireTest extends TestCase {
|
||||
|
||||
private const A = 'wp_itest_wpdo_user_rt_a';
|
||||
private const B = 'wp_itest_wpdo_user_rt_b';
|
||||
private const META = 'wp_itest_wpdo_registry_meta';
|
||||
|
||||
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/migration/class-tmdo-native-restorer.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->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' );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user