feat(2meet-data-optimizer): repair drifted flat rows from native with drift-check --fix

- TMDO_Drift_Check::fix() deletes flat rows of deleted entities and, for post groups, of posts outside the group's post types; rebuilds every other drifted entity's row from native through the new TMDO_Entity_Migration_Engine::migrate_entity() (keys without native rows become NULL, PR-066); keeps a row whose entity has no native value for the group but clears its columns (no NULL-only inserts, no deleted rows the router may join on); invalidates the row cache of each touched entity; then checks again. Native meta is never written.
- `wp tmdo drift-check <entity> --fix` (not combinable with --sample).
- Verify: DriftCheckTest fix cases (RED: method missing); dev36 --fix: post 42 rows rebuilt, 1 stray row (post 312, hp_booking in hp_draft_flags) deleted, all four entities 0 drift after, checker miss/extra 0.
- PR: PR-071
This commit is contained in:
2026-09-27 00:07:33 +08:00
parent 02045c6f07
commit 3a347269c8
4 changed files with 222 additions and 29 deletions
+21 -3
View File
@@ -187,11 +187,11 @@ class TMDO_CLI_V2 {
}
/**
* Compare an entity's flat tables with native meta (read-only).
* Compare an entity's flat tables with native meta, optionally repairing them.
*
* Reports per key how many values are missing from flat (miss), differ
* (diff) or exist only in flat (extra), and records the outcome per group
* in the wpdo_drift_status option.
* in the wpdo_drift_status option. Read-only unless --fix is given.
*
* ## OPTIONS
*
@@ -204,6 +204,11 @@ class TMDO_CLI_V2 {
* [--sample=<n>]
* : Check n random entities per group instead of all of them.
*
* [--fix]
* : Rebuild drifted flat rows from native meta (native is never written),
* delete rows of deleted entities / other post types, then check again.
* Not combinable with --sample.
*
* [--format=<format>]
* : table or json. Default: table.
*
@@ -212,16 +217,29 @@ class TMDO_CLI_V2 {
* wp tmdo drift-check post
* wp tmdo drift-check user --group=activity
* wp tmdo drift-check post --sample=500
* wp tmdo drift-check post --fix
*
* @param array<int, string> $args Entity type.
* @param array<string, string> $assoc_args Options.
*/
public function drift_check( $args, $assoc_args ): void {
$entity = (string) ( $args[0] ?? '' );
$group = (string) ( $assoc_args['group'] ?? '' );
$sample = max( 0, (int) ( $assoc_args['sample'] ?? 0 ) );
$fix = isset( $assoc_args['fix'] );
if ( $fix && $sample ) {
WP_CLI::error( '--fix repairs every entity; it cannot be combined with --sample.' );
return;
}
try {
$report = TMDO_Drift_Check::run( $entity, (string) ( $assoc_args['group'] ?? '' ), $sample );
if ( $fix ) {
$result = TMDO_Drift_Check::fix( $entity, $group );
$report = $result['after'];
WP_CLI::log( sprintf( 'Fixed: %d row(s) rebuilt from native, %d cleared, %d stray row(s) deleted.', $result['fixed']['rebuilt'], $result['fixed']['cleared'], $result['fixed']['deleted_rows'] ) );
} else {
$report = TMDO_Drift_Check::run( $entity, $group, $sample );
}
} catch ( InvalidArgumentException $e ) {
WP_CLI::error( $e->getMessage() );
return;
+139 -26
View File
@@ -13,8 +13,8 @@
* (a required column holding its default counts as absent); rows of
* another post type or of a deleted entity count too
*
* Nothing is written except the per-group outcome in the wpdo_drift_status
* option, which later steps (drift-check --fix, the router) read.
* run() writes nothing except the per-group outcome in the wpdo_drift_status
* option. fix() rebuilds the drifted rows from native (native is never written).
*
* @package WP_Data_Optimizer
* @since 1.2.0
@@ -57,17 +57,7 @@ final class TMDO_Drift_Check {
* @throws InvalidArgumentException Unknown entity type or group.
*/
public static function run( string $entity_type, string $group = '', int $sample = 0 ): array {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
throw new InvalidArgumentException( 'Unknown entity type: ' . esc_html( $entity_type ) );
}
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
if ( '' !== $group ) {
if ( ! in_array( $group, $groups, true ) ) {
throw new InvalidArgumentException( 'Unknown or orphan ' . esc_html( $entity_type ) . ' group: ' . esc_html( $group ) );
}
$groups = array( $group );
}
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
$report = array(
'entity' => $entity_type,
@@ -88,6 +78,7 @@ final class TMDO_Drift_Check {
continue;
}
$result = self::check_group( $adapter, $entity_type, $name, $table, $sample );
unset( $result['ids'] );
$report['groups'][ $name ] = $result;
foreach ( array( 'miss', 'diff', 'extra' ) as $kind ) {
@@ -106,6 +97,81 @@ final class TMDO_Drift_Check {
return $report;
}
/**
* Rebuild drifted flat rows from native meta, then check again.
*
* Rows of deleted entities (or, for post groups, of posts outside the
* group's post types) are deleted. Every other entity with drift in any key
* gets its whole row rebuilt from native; when native holds none of the
* group's keys the row is kept with its columns cleared. Row caches of the
* touched entities are invalidated. Native meta is never written.
*
* @param string $entity_type post | user | term | comment.
* @param string $group One group, or '' for every backfillable group.
* @return array{fixed:array{rebuilt:int, cleared:int, deleted_rows:int}, after:array<string, mixed>}
* @throws InvalidArgumentException Unknown entity type or group.
*/
public static function fix( string $entity_type, string $group = '' ): array {
global $wpdb;
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
$id = $adapter->get_entity_id_column();
$fixed = array(
'rebuilt' => 0,
'cleared' => 0,
'deleted_rows' => 0,
);
foreach ( $groups as $name ) {
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
foreach ( self::stray_ids( $adapter, $entity_type, $name, $table ) as $eid ) {
$fixed['deleted_rows'] += (int) $wpdb->delete( $table, array( $id => $eid ), array( '%d' ) );
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
}
foreach ( self::check_group( $adapter, $entity_type, $name, $table, 0 )['ids'] as $eid ) {
if ( TMDO_Entity_Migration_Engine::migrate_entity( $entity_type, $name, $eid ) ) {
++$fixed['rebuilt'];
} else {
self::clear_row( $entity_type, $name, $table, $id, $eid );
++$fixed['cleared'];
}
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
}
}
return array(
'fixed' => $fixed,
'after' => self::run( $entity_type, $group ),
);
}
/**
* Adapter and group list for a run.
*
* @param string $entity_type Entity type.
* @param string $group One group, or '' for every backfillable group.
* @return array{0:TMDO_Entity_Adapter_Interface, 1:string[]}
* @throws InvalidArgumentException Unknown entity type or group.
*/
private static function resolve( string $entity_type, string $group ): array {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
throw new InvalidArgumentException( 'Unknown entity type: ' . esc_html( $entity_type ) );
}
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
if ( '' !== $group ) {
if ( ! in_array( $group, $groups, true ) ) {
throw new InvalidArgumentException( 'Unknown or orphan ' . esc_html( $entity_type ) . ' group: ' . esc_html( $group ) );
}
$groups = array( $group );
}
return array( $adapter, $groups );
}
/**
* Compare every key of one group.
*
@@ -114,7 +180,7 @@ final class TMDO_Drift_Check {
* @param string $group Group name.
* @param string $table Group flat table.
* @param int $sample 0, or the number of random entities.
* @return array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>}
* @return array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>, ids:int[]}
*/
private static function check_group( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table, int $sample ): array {
global $wpdb;
@@ -128,6 +194,7 @@ final class TMDO_Drift_Check {
'diff' => 0,
'extra' => 0,
'keys' => array(),
'ids' => array(),
);
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
@@ -141,18 +208,18 @@ final class TMDO_Drift_Check {
// Text beyond the column width is stored as NULL by design (Type_Caster::fit_text).
$fits = 'text' === $type ? ' AND CHAR_LENGTH(n.v) <= ' . TMDO_Type_Caster::TEXT_MAX_CHARS : '';
$miss = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( {$native} ) n LEFT JOIN `{$table}` f ON f.`{$id}` = n.eid WHERE LENGTH(n.v) > 0 AND f.`{$col}` IS NULL{$fits}" );
$miss = array_map( 'intval', $wpdb->get_col( "SELECT n.eid FROM ( {$native} ) n LEFT JOIN `{$table}` f ON f.`{$id}` = n.eid WHERE LENGTH(n.v) > 0 AND f.`{$col}` IS NULL{$fits}" ) );
$candidates = $wpdb->get_results(
"SELECT n.v, f.`{$col}` AS flat FROM ( {$native} ) n JOIN `{$table}` f ON f.`{$id}` = n.eid
"SELECT n.eid, n.v, f.`{$col}` AS flat FROM ( {$native} ) n JOIN `{$table}` f ON f.`{$id}` = n.eid
WHERE f.`{$col}` IS NOT NULL AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY n.v
OR ( n.v REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6)) ) )",
ARRAY_A
);
$diff = 0;
$diff = array();
foreach ( (array) $candidates as $row ) {
if ( ! self::same( (string) $row['v'], (string) $row['flat'], $field ) ) {
++$diff;
$diff[] = (int) $row['eid'];
}
}
@@ -168,21 +235,67 @@ final class TMDO_Drift_Check {
}
// Every flat row counts, including rows of other post types or deleted
// entities; only sample mode narrows the rows.
$extra = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` f WHERE f.`{$col}` IS NOT NULL AND {$absent}" . ( $sample > 0 ? str_replace( '{id}', "f.`{$id}`", $scope ) : '' ) );
$extra = array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f WHERE f.`{$col}` IS NOT NULL AND {$absent}" . ( $sample > 0 ? str_replace( '{id}', "f.`{$id}`", $scope ) : '' ) ) );
$result['keys'][ $key ] = array(
'miss' => $miss,
'diff' => $diff,
'extra' => $extra,
$counts = array(
'miss' => count( $miss ),
'diff' => count( $diff ),
'extra' => count( $extra ),
);
$result['miss'] += $miss;
$result['diff'] += $diff;
$result['extra'] += $extra;
$result['keys'][ $key ] = $counts;
$result['miss'] += $counts['miss'];
$result['diff'] += $counts['diff'];
$result['extra'] += $counts['extra'];
$result['ids'] = array_merge( $result['ids'], $miss, $diff, $extra );
}
$result['ids'] = array_values( array_unique( $result['ids'] ) );
return $result;
}
/**
* Flat rows whose entity no longer exists or, for post groups, whose post
* is outside the group's post types.
*
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Group flat table.
* @return int[]
*/
private static function stray_ids( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table ): array {
global $wpdb;
$id = $adapter->get_entity_id_column();
$primary = $adapter->get_primary_table();
$pk = $adapter->get_primary_id_column();
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
$type_sql = $post_types ? " AND p.post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')" : '';
return array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f LEFT JOIN `{$primary}` p ON p.`{$pk}` = f.`{$id}`{$type_sql} WHERE p.`{$pk}` IS NULL" ) );
}
/**
* Clear a row's columns: native holds none of the group's keys. The row is
* kept (NULL-only rows are not deleted, as the router may join on them);
* required columns go back to their default.
*
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Group flat table.
* @param string $id Entity id column.
* @param int $eid Entity id.
*/
private static function clear_row( string $entity_type, string $group, string $table, string $id, int $eid ): void {
global $wpdb;
$data = array();
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
$data[ TMDO_Schema_Manager::sanitize_column_name( (string) $field['key'] ) ] = TMDO_Type_Caster::to_db( null, $field );
}
$wpdb->update( $table, $data, array( $id => $eid ) );
}
/**
* SQL restricting the checked entities: the group's post types, and in
* sample mode a random set of entity ids. `{id}` stands for the id column.
@@ -182,6 +182,28 @@ final class TMDO_Entity_Migration_Engine {
return $stats;
}
/**
* Rebuild one entity's row of a group from native meta.
*
* @return bool False when native holds none of the group's keys (nothing written).
*/
public static function migrate_entity( string $entity_type, string $group_name, int $entity_id ): bool {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
if ( ! $adapter || empty( $group_fields ) ) {
return false;
}
return self::migrate_single_entity(
$entity_type,
$group_name,
$entity_id,
array_column( $group_fields, 'key' ),
array_column( $group_fields, null, 'key' ),
$adapter,
false
);
}
/**
* 遷移單一 entity 的所有 meta
*/
+40
View File
@@ -141,6 +141,46 @@ class DriftCheckTest extends TestCase {
$this->assertSame( $before, $this->snapshot() );
}
// ── fix() (2.2 PR-071) ──────────────────────────────────────────────────
private function flat_row( int $user_id ): ?array {
global $wpdb;
return $wpdb->get_row( $wpdb->prepare( 'SELECT num, cnt, name, flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A );
}
public function test_fix_rebuilds_drifted_rows_from_native_and_leaves_no_drift(): void {
$result = TMDO_Drift_Check::fix( 'user' );
$this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $result['after']['total'] );
$this->assertSame( 'b', $this->flat_row( 2 )['name'] );
$this->assertSame( '5', $this->flat_row( 2 )['cnt'] );
$this->assertSame( 'c', $this->flat_row( 3 )['name'] );
$this->assertSame( array( 'num' => null, 'cnt' => '7', 'name' => null, 'flag' => '0' ), $this->flat_row( 7 ), 'new row: no invented defaults' );
$this->assertSame( array( 'num' => null, 'cnt' => null, 'name' => null, 'flag' => '0' ), $this->flat_row( 4 ), 'no native values: row kept, columns cleared' );
$this->assertSame( 0, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['diff'] );
}
public function test_fix_deletes_rows_of_entities_that_no_longer_exist(): void {
$this->flat( array( 'user_id' => 99, 'name' => 'ghost' ) );
$result = TMDO_Drift_Check::fix( 'user' );
$this->assertSame( 1, $result['fixed']['deleted_rows'] );
$this->assertNull( $this->flat_row( 99 ) );
$this->assertNotNull( $this->flat_row( 1 ), 'rows of existing users stay' );
}
public function test_fix_never_writes_native_and_invalidates_the_row_cache(): void {
global $wpdb;
$native = md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) );
TMDO_Cache_Orchestrator::set_row( 'user', 2, 'dc_grp', array( 'name' => 'B' ) );
TMDO_Drift_Check::fix( 'user' );
$this->assertSame( $native, md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) ) );
$this->assertFalse( TMDO_Cache_Orchestrator::get_row( 'user', 2, 'dc_grp' ) );
}
public function test_rejects_an_unknown_group(): void {
$this->expectException( InvalidArgumentException::class );
TMDO_Drift_Check::run( 'user', 'no_such_group' );