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