feat(2meet-data-optimizer): report flat-vs-native drift with wp tmdo drift-check

- New TMDO_Drift_Check compares every backfillable group of post / user / term / comment with native meta by value (first native row → safe unserialize → Type_Caster::to_db): miss / diff / extra per key, set-based SQL with BINARY exact matches, PHP comparison only for the remaining candidates. Serialized-vs-JSON and 50 vs 50.000000 are no longer drift; over-long text stored as NULL is not a miss; extra covers rows of other post types. Outcome per group goes to the wpdo_drift_status option; nothing else is written.
- `wp tmdo drift-check <entity> [--group] [--sample=<n>] [--format]`. A new class instead of extending the post-only shadow verifier, which queries row by row and writes shadow_diffs.
- Verify: DriftCheckTest; live suite drift; dev36 post miss=533 diff=2 extra=1 in 23 s (checker's 45 json false diffs gone), user/term/comment 0.
- PR: PR-070
This commit is contained in:
2026-09-26 23:56:23 +08:00
parent 382786cb9d
commit 02045c6f07
7 changed files with 543 additions and 1 deletions
+1
View File
@@ -268,6 +268,7 @@ add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'b
require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
+75
View File
@@ -186,6 +186,79 @@ class TMDO_CLI_V2 {
WP_CLI::success( "{$entity}: {$from} → {$mode}" );
}
/**
* Compare an entity's flat tables with native meta (read-only).
*
* 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.
*
* ## OPTIONS
*
* <entity>
* : post | user | term | comment
*
* [--group=<group>]
* : Only this group. Default: every backfillable group.
*
* [--sample=<n>]
* : Check n random entities per group instead of all of them.
*
* [--format=<format>]
* : table or json. Default: table.
*
* ## EXAMPLES
*
* wp tmdo drift-check post
* wp tmdo drift-check user --group=activity
* wp tmdo drift-check post --sample=500
*
* @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] ?? '' );
$sample = max( 0, (int) ( $assoc_args['sample'] ?? 0 ) );
try {
$report = TMDO_Drift_Check::run( $entity, (string) ( $assoc_args['group'] ?? '' ), $sample );
} catch ( InvalidArgumentException $e ) {
WP_CLI::error( $e->getMessage() );
return;
}
if ( 'json' === ( $assoc_args['format'] ?? 'table' ) ) {
WP_CLI::line( (string) wp_json_encode( $report ) );
return;
}
$rows = array();
foreach ( $report['groups'] as $group => $result ) {
foreach ( $result['keys'] as $key => $counts ) {
if ( $counts['miss'] + $counts['diff'] + $counts['extra'] > 0 ) {
$rows[] = array_merge(
array(
'group' => $group,
'key' => $key,
),
$counts
);
}
}
}
if ( $rows ) {
\WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'key', 'miss', 'diff', 'extra' ) );
}
$total = $report['total'];
$summary = sprintf( '%s: %d group(s)%s — miss=%d diff=%d extra=%d', $entity, count( $report['groups'] ), $sample ? " (sample {$sample})" : '', $total['miss'], $total['diff'], $total['extra'] );
if ( $total['miss'] + $total['diff'] + $total['extra'] > 0 ) {
WP_CLI::warning( $summary );
return;
}
WP_CLI::success( $summary );
}
/**
* Write an entity's flat-table values back to native meta — the way out of
* aeav_only. Resumable; re-run after an interruption.
@@ -954,6 +1027,8 @@ WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'wpdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
WP_CLI::add_command( 'tmdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
WP_CLI::add_command( 'wpdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
WP_CLI::add_command( 'tmdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
@@ -0,0 +1,240 @@
<?php
/**
* TMDO_Drift_Check — read-only flat-vs-native comparison for every entity type.
*
* For each backfillable group and key, the flat column is compared with what
* native meta would put there (first native row → safe unserialize →
* TMDO_Type_Caster::to_db()):
*
* miss — native holds a non-empty value, the flat column is NULL or has no row
* diff — both hold a value and the values differ (numbers, JSON and
* serialized values are compared by value, not by formatting)
* extra — the flat column holds a value, native has no row for the key
* (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.
*
* @package WP_Data_Optimizer
* @since 1.2.0
*/
// phpcs:ignore WPDO.AntiEAV -- drift checker: must read raw meta to compare it with the flat tables
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Read-only checker: table and column names come from the registry / adapters, meta keys and ids go through prepare() or intval.
/**
* Flat-vs-native drift checker.
*/
final class TMDO_Drift_Check {
/**
* Option holding the last outcome per entity / group.
*/
public const OPTION = 'wpdo_drift_status';
/**
* MySQL regex for a plain decimal number, used to skip rows that obviously match.
* Exact matches compare BINARY: meta and flat tables may differ in collation,
* and a case-insensitive one would hide 'b' vs 'B'.
*/
private const SQL_NUMERIC = '^-?[0-9]+(\\\\.[0-9]+)?$';
/**
* Compare one entity type's flat tables with native meta.
*
* @param string $entity_type post | user | term | comment.
* @param string $group One group, or '' for every backfillable group.
* @param int $sample 0 checks every entity; N checks N random entities per group.
* @return array{entity:string, sample:int, groups:array<string, array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>}>, total:array{miss:int, diff:int, extra:int}}
* @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 );
}
$report = array(
'entity' => $entity_type,
'sample' => $sample,
'groups' => array(),
'total' => array(
'miss' => 0,
'diff' => 0,
'extra' => 0,
),
);
$status = get_option( self::OPTION, array() );
$status = is_array( $status ) ? $status : array();
foreach ( $groups as $name ) {
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
$result = self::check_group( $adapter, $entity_type, $name, $table, $sample );
$report['groups'][ $name ] = $result;
foreach ( array( 'miss', 'diff', 'extra' ) as $kind ) {
$report['total'][ $kind ] += $result[ $kind ];
}
$status[ $entity_type ][ $name ] = array(
'miss' => $result['miss'],
'diff' => $result['diff'],
'extra' => $result['extra'],
'sample' => $sample,
'checked_at' => gmdate( 'Y-m-d H:i:s' ),
);
}
update_option( self::OPTION, $status );
return $report;
}
/**
* Compare every key of one group.
*
* @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.
* @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}>}
*/
private static function check_group( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table, int $sample ): array {
global $wpdb;
$meta = $adapter->get_native_meta_table();
$id = $adapter->get_entity_id_column();
$mid = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
$scope = self::scope_sql( $adapter, $entity_type, $group, $sample );
$result = array(
'miss' => 0,
'diff' => 0,
'extra' => 0,
'keys' => array(),
);
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
$key = (string) $field['key'];
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$type = $field['type'] ?? 'text';
// First native row per entity — what a single get_*_meta() read returns.
$native = $wpdb->prepare( "SELECT m.`{$id}` AS eid, m.meta_value AS v FROM `{$meta}` m JOIN ( SELECT `{$id}`, MIN(`{$mid}`) AS mid FROM `{$meta}` WHERE meta_key = %s GROUP BY `{$id}` ) x ON x.mid = m.`{$mid}` WHERE 1=1", $key )
. str_replace( '{id}', "m.`{$id}`", $scope );
// 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}" );
$candidates = $wpdb->get_results(
"SELECT 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;
foreach ( (array) $candidates as $row ) {
if ( ! self::same( (string) $row['v'], (string) $row['flat'], $field ) ) {
++$diff;
}
}
$absent = $wpdb->prepare( "NOT EXISTS ( SELECT 1 FROM `{$meta}` m WHERE m.`{$id}` = f.`{$id}` AND m.meta_key = %s )", $key );
if ( ! empty( $field['required'] ) ) {
$default = (string) TMDO_Type_Caster::to_db( null, $field );
$absent .= $wpdb->prepare(
" AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY %s OR ( %s REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(%s AS DECIMAL(30,6)) ) )",
$default,
$default,
$default
);
}
// 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 ) : '' ) );
$result['keys'][ $key ] = array(
'miss' => $miss,
'diff' => $diff,
'extra' => $extra,
);
$result['miss'] += $miss;
$result['diff'] += $diff;
$result['extra'] += $extra;
}
return $result;
}
/**
* 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.
*
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param int $sample 0, or the number of random entities.
*/
private static function scope_sql( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, int $sample ): string {
global $wpdb;
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
$type_sql = $post_types
? " post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')"
: '';
if ( $sample <= 0 ) {
return $type_sql ? " AND {id} IN ( SELECT ID FROM {$wpdb->posts} WHERE{$type_sql} )" : '';
}
$primary = $adapter->get_primary_table();
$pk = $adapter->get_primary_id_column();
$ids = $wpdb->get_col( "SELECT `{$pk}` FROM `{$primary}`" . ( $type_sql ? " WHERE{$type_sql}" : '' ) . ' ORDER BY RAND() LIMIT ' . (int) $sample );
return ' AND {id} IN (' . ( $ids ? implode( ',', array_map( 'intval', $ids ) ) : '0' ) . ')';
}
/**
* Whether a flat value is what native meta would store.
*
* @param string $native Raw native meta_value.
* @param string $flat Flat column value.
* @param array<string, mixed> $field Registered field definition.
*/
private static function same( string $native, string $flat, array $field ): bool {
$type = $field['type'] ?? 'text';
$value = TMDO_Safe_Unserialize::run( $native );
if ( 'text' === $type && is_scalar( $value ) && mb_strlen( (string) $value ) > TMDO_Type_Caster::TEXT_MAX_CHARS ) {
return false; // Belongs in flat as NULL, yet this row holds a value.
}
$expected = TMDO_Type_Caster::to_db( $value, $field );
if ( null === $expected ) {
return false;
}
if ( 'json' === $type ) {
// Key order and int-vs-float formatting are not drift.
return json_decode( $flat, true ) == json_decode( (string) $expected, true ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
}
if ( is_numeric( $expected ) && is_numeric( $flat ) ) {
return abs( (float) $expected - (float) $flat ) < 0.000001;
}
return (string) $expected === $flat;
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ final class TMDO_Type_Caster {
}
/** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */
private const TEXT_MAX_CHARS = 255;
public const TEXT_MAX_CHARS = 255;
/**
* Keep a text value only if it fits its VARCHAR(255) column; otherwise
+148
View File
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* TMDO_Drift_Check (2.2 PR-070): read-only flat-vs-native comparison.
*
* Fixture (user entity, group dc_grp):
* user 1 every key equal in value, differently formatted
* (50 vs 50.000000, serialized array vs JSON, required flag holding
* its default with no native row) → clean
* user 2 name 'b' vs 'B', cnt 5 vs 6 → diff ×2
* user 3 native name, flat row with name NULL → miss
* user 4 no native rows, flat cnt 9 → extra
* user 5 native name '', flat NULL → clean
* user 6 native name longer than a text column, flat NULL → clean
* user 7 native cnt, no flat row → miss
*/
class DriftCheckTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_dc_grp';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/diagnostic/class-tmdo-drift-check.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->users}` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_login VARCHAR(60) NOT NULL DEFAULT '', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
$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) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, num DECIMAL(18,6) DEFAULT NULL, cnt BIGINT(20) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, prefs TEXT DEFAULT NULL, meta_json LONGTEXT DEFAULT NULL, flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
for ( $id = 1; $id <= 7; $id++ ) {
$wpdb->insert( $wpdb->users, array( 'ID' => $id, 'user_login' => "u{$id}" ) );
}
$prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) );
$this->native( 1, array( 'num' => '50', 'cnt' => '3', 'name' => 'a', 'prefs' => $prefs, 'meta_json' => serialize( array( 'w' => 640, 'tags' => array( 'x' ) ) ) ) );
$this->flat( array( 'user_id' => 1, 'num' => '50.000000', 'cnt' => 3, 'name' => 'a', 'prefs' => $prefs, 'meta_json' => '{"w":640,"tags":["x"]}' ) );
$this->native( 2, array( 'name' => 'b', 'cnt' => '5' ) );
$this->flat( array( 'user_id' => 2, 'name' => 'B', 'cnt' => 6 ) );
$this->native( 3, array( 'name' => 'c' ) );
$this->flat( array( 'user_id' => 3, 'cnt' => null ) );
$this->flat( array( 'user_id' => 4, 'cnt' => 9 ) );
$this->native( 5, array( 'name' => '' ) );
$this->flat( array( 'user_id' => 5 ) );
$this->native( 6, array( 'name' => str_repeat( 'x', 300 ) ) );
$this->flat( array( 'user_id' => 6 ) );
$this->native( 7, array( 'cnt' => '7' ) );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group(
'user',
'dc_grp',
array(
array( 'key' => 'num', 'type' => 'decimal' ),
array( 'key' => 'cnt', 'type' => 'integer' ),
array( 'key' => 'name', 'type' => 'text' ),
array( 'key' => 'prefs', 'type' => 'textarea' ),
array( 'key' => 'meta_json', 'type' => 'json' ),
array( 'key' => 'flag', 'type' => 'integer', 'default' => 0, 'required' => true ),
)
);
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->users, $wpdb->usermeta, self::TABLE ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function native( int $user_id, array $values ): void {
global $wpdb;
foreach ( $values as $key => $value ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) );
}
}
private function flat( array $row ): void {
global $wpdb;
$wpdb->insert( self::TABLE, $row );
}
private function snapshot(): string {
global $wpdb;
return md5( wp_json_encode( array( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ), $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) );
}
public function test_counts_miss_diff_and_extra_per_key(): void {
$report = TMDO_Drift_Check::run( 'user' );
$this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] );
$keys = $report['groups']['dc_grp']['keys'];
$this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 0 ), $keys['name'] );
$this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 1 ), $keys['cnt'] );
foreach ( array( 'num', 'prefs', 'meta_json', 'flag' ) as $key ) {
$this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $keys[ $key ], "{$key}: equal by value" );
}
}
public function test_records_the_outcome_per_group(): void {
TMDO_Drift_Check::run( 'user', 'dc_grp' );
$status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp'];
$this->assertSame( 2, $status['miss'] );
$this->assertSame( 2, $status['diff'] );
$this->assertSame( 1, $status['extra'] );
$this->assertSame( 0, $status['sample'] );
$this->assertNotEmpty( $status['checked_at'] );
}
public function test_a_sample_covering_every_user_matches_the_full_check(): void {
$report = TMDO_Drift_Check::run( 'user', '', 100 );
$this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] );
$this->assertSame( 100, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['sample'] );
}
public function test_never_writes_native_or_flat(): void {
$before = $this->snapshot();
TMDO_Drift_Check::run( 'user' );
$this->assertSame( $before, $this->snapshot() );
}
public function test_rejects_an_unknown_group(): void {
$this->expectException( InvalidArgumentException::class );
TMDO_Drift_Check::run( 'user', 'no_such_group' );
}
}
+1
View File
@@ -41,6 +41,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test:
| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) |
| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route, meta orderby: HivePress sort shape, named clause, meta_key + meta_value_num); routed SQL has no GROUP BY |
| `rest` | anonymous GET of the public listings endpoints returns no protected or credential-like field (1.1.1) |
| `drift` | `TMDO_Drift_Check`: json groups show no false diff; a planted flat mismatch / flat-only value counts exactly once (1.2.0) |
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
## Tools (`tools/`)
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* Suite: TMDO_Drift_Check on a real site (2.2 PR-070).
*
* - json groups (attachment / nav_menu_item) compare by value: a serialized
* native array and its JSON flat copy are not drift.
* - a flat value planted behind the checker's back shows up as exactly one
* diff / one extra, nothing else moves.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* Run the drift check for one group and put the site's recorded status back.
*
* @param string $entity Entity type.
* @param string $group Group.
* @return array<string, mixed> Report.
*/
function tmdo_live_drift( string $entity, string $group ): array {
$status = get_option( TMDO_Drift_Check::OPTION, null );
try {
return TMDO_Drift_Check::run( $entity, $group );
} finally {
null === $status ? delete_option( TMDO_Drift_Check::OPTION ) : update_option( TMDO_Drift_Check::OPTION, $status );
}
}
TMDO_Live::test(
'drift-check reports no diff for json groups whose values match native',
static function () {
$checked = 0;
foreach ( array( 'attachment', 'nav_menu_item' ) as $group ) {
if ( ! in_array( $group, TMDO_Entity_Migration_Engine::backfillable_groups( 'post' ), true )
|| ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( 'post', $group ) ) ) {
continue;
}
$report = tmdo_live_drift( 'post', $group );
tmdo_live_assert_same( 0, $report['total']['diff'], "post/{$group} diff" );
++$checked;
}
if ( 0 === $checked ) {
throw new TMDO_Live_Skip( 'no attachment / nav_menu_item flat table on this site' );
}
}
);
TMDO_Live::test(
'drift-check counts a planted flat mismatch once and a flat-only value once',
static function () {
global $wpdb;
TMDO_Live_Fixtures::use_modes( array( 'user' => 'disabled' ) );
$field = TMDO_Live_Fixtures::field( 'user', 'login_count' );
$group = $field['group'];
$table = TMDO_Schema_Manager::get_table_name( 'user', $group );
$before = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count'];
$mismatch = TMDO_Live_Fixtures::user();
$flat_only = TMDO_Live_Fixtures::user();
add_user_meta( $mismatch, 'login_count', '5' );
try {
$wpdb->insert( $table, array( 'user_id' => $mismatch, 'login_count' => 6 ) );
$wpdb->insert( $table, array( 'user_id' => $flat_only, 'login_count' => 9 ) );
$after = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count'];
} finally {
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE user_id IN (%d, %d)", $mismatch, $flat_only ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
tmdo_live_assert_same( $before['diff'] + 1, $after['diff'], 'login_count diff +1' );
tmdo_live_assert_same( $before['extra'] + 1, $after['extra'], 'login_count extra +1' );
tmdo_live_assert_same( $before['miss'], $after['miss'], 'login_count miss unchanged' );
}
);