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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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' );
|
||||
|
||||
Reference in New Issue
Block a user