29adb05567
- security review H1: anonymous /wp-cron.php requests pass ddl_allowed(), and dbDelta on an existing table had no lock, so concurrent requests each ran the ALTER on a large table. The upgrade now runs under the same MySQL GET_LOCK as the index DDL (skipped when another process holds it) and re-checks the stored hash once locked; this covers aeav_only too. The table build moves unchanged to write_table(); lock()/unlock() are shared - security review M1: after a deferred upgrade the group stays native until tmdo_drift_resync has rebuilt the rows written meanwhile (TMDO_Schema_Manager::resynced() from run_scheduled_fix()); with no flat writes (disabled) it is released at once - Verify: SchemaUpgradeDeferralTest (lock held elsewhere, resync release), integration 585, unit 702, phpcs, stan - PR: PR-111 (2.3 acceptance security review)
534 lines
21 KiB
PHP
534 lines
21 KiB
PHP
<?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
|
|
*
|
|
* 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
|
|
*/
|
|
|
|
// 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';
|
|
|
|
/**
|
|
* Entities per group sampled by the daily check.
|
|
*/
|
|
public const CRON_SAMPLE = 500;
|
|
|
|
/**
|
|
* Single WP-Cron event rebuilding an entity's flat tables from native after
|
|
* writes bypassed them. Argument: the entity type.
|
|
*/
|
|
public const RESYNC_HOOK = 'tmdo_drift_resync';
|
|
|
|
/**
|
|
* Seconds before the scheduled rebuild, so the switch that triggered it
|
|
* finishes first.
|
|
*/
|
|
private const RESYNC_DELAY = 60;
|
|
|
|
/**
|
|
* 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 {
|
|
list( $adapter, $groups ) = self::resolve( $entity_type, $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 );
|
|
unset( $result['ids'] );
|
|
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* Every backfillable group of the entity was checked within $max_age
|
|
* seconds (sampled or full) and none drifted. No record counts as unclean.
|
|
*
|
|
* @param string $entity_type Entity type.
|
|
* @param int $max_age Seconds.
|
|
*/
|
|
public static function clean_since( string $entity_type, int $max_age ): bool {
|
|
$status = get_option( self::OPTION, array() );
|
|
$status = is_array( $status ) ? ( $status[ $entity_type ] ?? array() ) : array();
|
|
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
|
|
if ( empty( $groups ) ) {
|
|
return false;
|
|
}
|
|
foreach ( $groups as $group ) {
|
|
$last = $status[ $group ] ?? null;
|
|
if ( ! is_array( $last )
|
|
|| (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0
|
|
|| (int) strtotime( ( $last['checked_at'] ?? '' ) . ' UTC' ) < time() - $max_age ) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Daily check (wpdo_daily_health_check): sample every entity type written
|
|
* to both native and flat (dual_write, shadow_read). In aeav_only flat is
|
|
* the source of truth and native is stale by design, so drift means nothing
|
|
* there. The outcome replaces the recorded status, so a group found
|
|
* drifting leaves the router at once.
|
|
*/
|
|
public static function cron_tick(): void {
|
|
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
|
if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) || ! TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
|
|
continue;
|
|
}
|
|
try {
|
|
self::run( $entity_type, '', self::CRON_SAMPLE );
|
|
} catch ( InvalidArgumentException $e ) {
|
|
continue; // No adapter for this entity type on this site.
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Entities to rebuild after a mode change: one whose flat tables were not
|
|
* written (disabled) is written next to native again, and values written
|
|
* meanwhile are missing from flat.
|
|
*
|
|
* @param string $entity_type Entity type.
|
|
* @param string $new_mode New mode.
|
|
* @param string $old_mode Previous mode.
|
|
* @return string[]
|
|
*/
|
|
public static function resync_after_mode_change( string $entity_type, string $new_mode, string $old_mode ): array {
|
|
$keeps_native = array( TMDO_Mode_Manager::MODE_DUAL_WRITE, TMDO_Mode_Manager::MODE_SHADOW_READ );
|
|
return TMDO_Mode_Manager::MODE_DISABLED === $old_mode && in_array( $new_mode, $keeps_native, true ) ? array( $entity_type ) : array();
|
|
}
|
|
|
|
/**
|
|
* Entities to rebuild after the Hook Bus flag changes: no write reached
|
|
* flat while it was off. Every entity written to both native and flat
|
|
* (aeav_only keeps flat as the source of truth).
|
|
*
|
|
* @param mixed $old_value Previous flag value.
|
|
* @param mixed $value New flag value.
|
|
* @return string[]
|
|
*/
|
|
public static function resync_after_hook_bus_change( $old_value, $value ): array {
|
|
if ( self::flag_on( $old_value ) || ! self::flag_on( $value ) ) {
|
|
return array();
|
|
}
|
|
$entities = array();
|
|
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
|
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
|
|
$entities[] = $entity_type;
|
|
}
|
|
}
|
|
return $entities;
|
|
}
|
|
|
|
/**
|
|
* `wpdo_bridge_mode_changed` handler.
|
|
*
|
|
* @param string $entity_type Entity type.
|
|
* @param string $new_mode New mode.
|
|
* @param string $old_mode Previous mode.
|
|
*/
|
|
public static function on_mode_changed( string $entity_type, string $new_mode, string $old_mode ): void {
|
|
self::schedule_resync( self::resync_after_mode_change( $entity_type, $new_mode, $old_mode ) );
|
|
}
|
|
|
|
/**
|
|
* `update_option_wpdo_hook_bus_enabled` handler.
|
|
*
|
|
* @param mixed $old_value Previous flag value.
|
|
* @param mixed $value New flag value.
|
|
*/
|
|
public static function on_hook_bus_toggled( $old_value, $value ): void {
|
|
self::schedule_resync( self::resync_after_hook_bus_change( $old_value, $value ) );
|
|
}
|
|
|
|
/**
|
|
* `add_option_wpdo_hook_bus_enabled` handler: the flag is stored for the first time.
|
|
*
|
|
* @param string $option Option name.
|
|
* @param mixed $value Flag value.
|
|
*/
|
|
public static function on_hook_bus_added( $option, $value ): void {
|
|
unset( $option );
|
|
self::schedule_resync( self::resync_after_hook_bus_change( '0', $value ) );
|
|
}
|
|
|
|
/**
|
|
* RESYNC_HOOK handler. An entity that has meanwhile moved to aeav_only is
|
|
* left alone (fix() refuses).
|
|
*
|
|
* @param string $entity_type Entity type.
|
|
*/
|
|
public static function run_scheduled_fix( string $entity_type ): void {
|
|
try {
|
|
$result = self::fix( $entity_type );
|
|
TMDO_Schema_Manager::resynced( $entity_type ); // Groups upgraded while deferred use the flat table again.
|
|
TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] );
|
|
} catch ( InvalidArgumentException | RuntimeException $e ) {
|
|
TMDO_Logger::info(
|
|
'drift_resync_skipped',
|
|
array(
|
|
'entity' => $entity_type,
|
|
'reason' => $e->getMessage(),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule one rebuild per entity type (not twice while one is pending).
|
|
*
|
|
* @param string[] $entities Entity types.
|
|
*/
|
|
private static function schedule_resync( array $entities ): void {
|
|
foreach ( $entities as $entity_type ) {
|
|
if ( ! wp_next_scheduled( self::RESYNC_HOOK, array( $entity_type ) ) ) {
|
|
wp_schedule_single_event( time() + self::RESYNC_DELAY, self::RESYNC_HOOK, array( $entity_type ) );
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a stored Hook Bus flag value means "on" (as TMDO_Hook_Bus_Bridge reads it).
|
|
*
|
|
* @param mixed $value Flag value.
|
|
*/
|
|
private static function flag_on( $value ): bool {
|
|
return '1' === (string) $value || true === $value;
|
|
}
|
|
|
|
/**
|
|
* 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<string, mixed>}
|
|
* @throws RuntimeException In aeav_only, where flat is the source of truth
|
|
* (and InvalidArgumentException via resolve() for an
|
|
* 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 );
|
|
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ) {
|
|
throw new RuntimeException( esc_html( $entity_type ) . ' is in aeav_only: flat is the source of truth and native meta is stale, so it cannot be rebuilt from native. Write flat back first (wp tmdo bridge-restore-native).' );
|
|
}
|
|
|
|
$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.
|
|
*
|
|
* @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}>, ids: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(),
|
|
'ids' => 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 = 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.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 = array();
|
|
foreach ( (array) $candidates as $row ) {
|
|
if ( ! self::same( (string) $row['v'], (string) $row['flat'], $field ) ) {
|
|
$diff[] = (int) $row['eid'];
|
|
}
|
|
}
|
|
|
|
$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 = 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 ) : '' ) ) );
|
|
|
|
$counts = array(
|
|
'miss' => count( $miss ),
|
|
'diff' => count( $diff ),
|
|
'extra' => count( $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.
|
|
*
|
|
* @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;
|
|
}
|
|
}
|