}>, 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} */ 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 $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; } }