diff --git a/cli/class-tmdo-cli-v2.php b/cli/class-tmdo-cli-v2.php index e99e9c3..5926c70 100644 --- a/cli/class-tmdo-cli-v2.php +++ b/cli/class-tmdo-cli-v2.php @@ -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=] * : 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=] * : 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 $args Entity type. * @param array $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; diff --git a/includes/diagnostic/class-tmdo-drift-check.php b/includes/diagnostic/class-tmdo-drift-check.php index 8353309..d0dc7ff 100644 --- a/includes/diagnostic/class-tmdo-drift-check.php +++ b/includes/diagnostic/class-tmdo-drift-check.php @@ -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} + * @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} + * @return array{miss:int, diff:int, extra:int, keys:array, 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. diff --git a/includes/engine/class-tmdo-entity-migration-engine.php b/includes/engine/class-tmdo-entity-migration-engine.php index 85d40e6..b891b75 100644 --- a/includes/engine/class-tmdo-entity-migration-engine.php +++ b/includes/engine/class-tmdo-entity-migration-engine.php @@ -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 */ diff --git a/tests/integration/DriftCheckTest.php b/tests/integration/DriftCheckTest.php index a54ee1e..f16af35 100644 --- a/tests/integration/DriftCheckTest.php +++ b/tests/integration/DriftCheckTest.php @@ -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' );