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
@@ -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