8c049f0cf2
- TMDO_Native_Restorer::run() gains a policy: overwrite (flat wins; the default in aeav_only, where writes only reached flat and native is stale) or only_missing (native wins, flat only fills gaps; the default in every other mode, where native is the source of truth and may be newer — on dev36 an aeav_only round trip overwrote WooCommerce stock 42 with 0). CLI: --overwrite / --only-missing. - Native values are read first and each owned key is classified as missing / differs / same / default_skipped; unchanged values are no longer rewritten. A flat value equal to its field default is not written for an entity without a native row (dev36: 1,368 invented session_count / account_flags rows). --dry-run reports the counts. - Verify: NativeRestorer* 17 OK (5 new: policy by mode, forced overwrite, only-missing in aeav_only, dry-run counts, no invented defaults); unit 687 OK, integration 490 OK, phpcs (phpcs.xml) 0, stan OK; dev36 `bridge-restore-native user --dry-run`: policy only_missing, activity 120 defaults skipped - PR: PR-058
439 lines
18 KiB
PHP
439 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* TMDO_Native_Restorer — write flat-table values back to native meta.
|
|
*
|
|
* The only way out of aeav_only: in that mode the flat tables are the sole
|
|
* copy of every managed key, and demoting the entity (or uninstalling) would
|
|
* leave reads on native meta that no longer has the data.
|
|
*
|
|
* Rule: afterwards native meta returns what reads return today.
|
|
* - A key is written from the group that currently owns it for that entity
|
|
* (TMDO_Entity_Registry::get_field(), by post type for posts), so stale
|
|
* copies in other group tables never win.
|
|
* - Non-NULL values are stored the way WP stores them (maybe_serialize of
|
|
* the value Type_Caster returns); a NULL value deletes the native row.
|
|
* - Entities with no row in the owning group's table are not touched:
|
|
* leftover native rows for them are outside the flat data.
|
|
* - A flat value equal to its field default is not written for an entity
|
|
* that has no native row (e.g. activity.session_count = 0): that would
|
|
* invent native meta the entity never had.
|
|
*
|
|
* Policy (which side wins where both hold a value):
|
|
* - overwrite — flat wins; the default in aeav_only, where writes only
|
|
* reached the flat tables and native is stale.
|
|
* - only_missing — native wins, flat only fills gaps; the default in every
|
|
* other mode, where native is the source of truth (and may
|
|
* be newer, e.g. WooCommerce stock written with direct SQL).
|
|
*
|
|
* Batched by entity id with a cursor option so an interrupted run resumes; a
|
|
* final pass re-copies rows updated since the first run started. With
|
|
* $demote the entity then moves to dual_write, which keeps both copies in
|
|
* step from then on.
|
|
*
|
|
* A single group can be written back on its own ($group) and then retired:
|
|
* retire() forgets its registry_meta row and keeps the flat table under a
|
|
* `_retired_<Ymd>` name, so orphan restore never routes its keys again.
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- table/column names come from the entity registry / adapter; placeholders are built for spread params.
|
|
|
|
/**
|
|
* Copies flat-table values of one entity type back into its native meta table.
|
|
*/
|
|
final class TMDO_Native_Restorer {
|
|
|
|
/** Option: "entity/group" → last entity id written. */
|
|
public const CURSOR_OPTION = 'tmdo_restore_native_cursor';
|
|
|
|
/** Option: entity → DB time its first (possibly interrupted) run started. */
|
|
public const STARTED_OPTION = 'tmdo_restore_native_started';
|
|
|
|
/**
|
|
* Restore one entity type (or, with $dry_run, count the rows involved).
|
|
*
|
|
* @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
|
|
* @param bool $dry_run Count only; write nothing.
|
|
* @param int $batch Rows per batch.
|
|
* @param bool $demote After a complete run, switch the entity to dual_write.
|
|
* @param string $group Only this group ('' = every group).
|
|
* @param string $policy 'overwrite' | 'only_missing' | '' (by the entity's mode).
|
|
* @return array{groups: array<string, array{rows:int, written:int, missing:int, differs:int, same:int, default_skipped:int}>, demoted: bool, policy: string}
|
|
*/
|
|
public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '', string $policy = '' ): array {
|
|
global $wpdb;
|
|
|
|
if ( ! in_array( $policy, array( 'overwrite', 'only_missing' ), true ) ) {
|
|
$policy = TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ? 'overwrite' : 'only_missing';
|
|
}
|
|
$report = array(
|
|
'groups' => array(),
|
|
'demoted' => false,
|
|
'policy' => $policy,
|
|
);
|
|
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
|
if ( ! $adapter ) {
|
|
return $report;
|
|
}
|
|
$id_col = $adapter->get_entity_id_column();
|
|
$batch = max( 1, $batch );
|
|
|
|
$started = get_option( self::STARTED_OPTION, array() );
|
|
$started = is_array( $started ) ? $started : array();
|
|
if ( ! $dry_run && ! isset( $started[ $entity_type ] ) ) {
|
|
$started[ $entity_type ] = (string) $wpdb->get_var( 'SELECT NOW()' );
|
|
update_option( self::STARTED_OPTION, $started, false );
|
|
}
|
|
$cursor = get_option( self::CURSOR_OPTION, array() );
|
|
$cursor = is_array( $cursor ) ? $cursor : array();
|
|
|
|
$tables = self::group_tables( $entity_type );
|
|
if ( '' !== $group ) {
|
|
$tables = array_intersect_key( $tables, array( $group => true ) );
|
|
}
|
|
foreach ( $tables as $group => $table ) {
|
|
$entry = array(
|
|
'rows' => 0,
|
|
'written' => 0,
|
|
'missing' => 0,
|
|
'differs' => 0,
|
|
'same' => 0,
|
|
'default_skipped' => 0,
|
|
);
|
|
$key = "{$entity_type}/{$group}";
|
|
$last = $dry_run ? 0 : (int) ( $cursor[ $key ] ?? 0 );
|
|
do {
|
|
$rows = self::fetch( $entity_type, $table, $id_col, "t.`{$id_col}` > %d ORDER BY t.`{$id_col}` LIMIT %d", array( $last, $batch ) );
|
|
$fetched = count( $rows );
|
|
$entry['rows'] += $fetched;
|
|
if ( $fetched ) {
|
|
$last = (int) end( $rows )[ $id_col ];
|
|
foreach ( self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, ! $dry_run ) as $count_key => $n ) {
|
|
$entry[ $count_key ] += $n;
|
|
}
|
|
if ( ! $dry_run ) {
|
|
$cursor[ $key ] = $last;
|
|
update_option( self::CURSOR_OPTION, $cursor, false );
|
|
}
|
|
}
|
|
} while ( $fetched === $batch );
|
|
|
|
$report['groups'][ $group ] = $entry;
|
|
}
|
|
|
|
if ( $dry_run ) {
|
|
return $report;
|
|
}
|
|
|
|
// Catch-up: writes that landed in the flat tables after a batch passed their row.
|
|
foreach ( $tables as $group => $table ) {
|
|
$rows = self::fetch( $entity_type, $table, $id_col, 't.updated_at >= %s', array( $started[ $entity_type ] ) );
|
|
self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, true );
|
|
}
|
|
|
|
foreach ( array_keys( $tables ) as $group ) {
|
|
unset( $cursor[ "{$entity_type}/{$group}" ] );
|
|
}
|
|
unset( $started[ $entity_type ] );
|
|
$cursor ? update_option( self::CURSOR_OPTION, $cursor, false ) : delete_option( self::CURSOR_OPTION );
|
|
$started ? update_option( self::STARTED_OPTION, $started, false ) : delete_option( self::STARTED_OPTION );
|
|
|
|
if ( $demote ) {
|
|
$report['demoted'] = true === TMDO_Mode_Manager::set( $entity_type, TMDO_Mode_Manager::MODE_DUAL_WRITE );
|
|
}
|
|
|
|
return $report;
|
|
}
|
|
|
|
/**
|
|
* Forget a group that no plugin registers any more, once native meta holds
|
|
* every non-NULL value of its flat table (run() with $group first).
|
|
*
|
|
* @param string $entity_type Entity type.
|
|
* @param string $group Group name.
|
|
* @return array{status:string, mismatches:int, archived_table:string}
|
|
* status: retired | registered (a plugin still registers it) |
|
|
* missing (no registry_meta row or flat table) | mismatch (native differs).
|
|
*/
|
|
public static function retire( string $entity_type, string $group ): array {
|
|
global $wpdb;
|
|
$result = array(
|
|
'status' => 'missing',
|
|
'mismatches' => 0,
|
|
'archived_table' => '',
|
|
);
|
|
$restored = array_map( static fn( $r ) => $r['entity_type'] . '/' . $r['group'], TMDO_Entity_Registry::get_restored_groups() );
|
|
if ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) && ! in_array( "{$entity_type}/{$group}", $restored, true ) ) {
|
|
$result['status'] = 'registered';
|
|
return $result;
|
|
}
|
|
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
|
$meta_table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
|
|
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
|
$defs = $wpdb->get_var( $wpdb->prepare( "SELECT field_definitions FROM `{$meta_table}` WHERE entity_type = %s AND group_name = %s", $entity_type, $group ) );
|
|
$fields = json_decode( (string) $defs, true );
|
|
if ( ! $adapter || ! is_array( $fields ) || null === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) {
|
|
return $result;
|
|
}
|
|
|
|
$native_table = $adapter->get_native_meta_table();
|
|
$id_col = $adapter->get_entity_id_column();
|
|
foreach ( (array) $wpdb->get_results( "SELECT * FROM `{$table}`", ARRAY_A ) as $row ) {
|
|
foreach ( $fields as $field ) {
|
|
$col = TMDO_Schema_Manager::sanitize_column_name( (string) ( $field['key'] ?? '' ) );
|
|
if ( '' === $col || ! isset( $row[ $col ] ) ) {
|
|
continue;
|
|
}
|
|
$want = (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) );
|
|
// get_results(), not get_col(): wpdb reads an empty string as null.
|
|
$native = array_column( (array) $wpdb->get_results( $wpdb->prepare( "SELECT meta_value FROM `{$native_table}` WHERE `{$id_col}` = %d AND meta_key = %s", (int) $row[ $id_col ], $field['key'] ), ARRAY_A ), 'meta_value' );
|
|
if ( array( $want ) !== $native ) {
|
|
++$result['mismatches'];
|
|
}
|
|
}
|
|
}
|
|
if ( $result['mismatches'] ) {
|
|
$result['status'] = 'mismatch';
|
|
return $result;
|
|
}
|
|
|
|
$archived = $table . '_retired_' . gmdate( 'Ymd' );
|
|
$wpdb->query( "RENAME TABLE `{$table}` TO `{$archived}`" );
|
|
$wpdb->delete(
|
|
$meta_table,
|
|
array(
|
|
'entity_type' => $entity_type,
|
|
'group_name' => $group,
|
|
)
|
|
);
|
|
delete_transient( 'tmdo_orphan_native_keys' );
|
|
TMDO_Schema_Manager::flush_table_exists_cache();
|
|
|
|
$result['status'] = 'retired';
|
|
$result['archived_table'] = $archived;
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Registered groups of the entity whose flat table exists.
|
|
*
|
|
* @param string $entity_type Entity type.
|
|
* @return array<string, string> Group → table name.
|
|
*/
|
|
private static function group_tables( string $entity_type ): array {
|
|
$tables = array();
|
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
|
|
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
|
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
|
|
$tables[ $group ] = $table;
|
|
}
|
|
}
|
|
return $tables;
|
|
}
|
|
|
|
/**
|
|
* Flat rows matching $where; post rows carry their post type as __post_type.
|
|
*
|
|
* @param string $entity_type Entity type.
|
|
* @param string $table Flat table.
|
|
* @param string $id_col Entity id column.
|
|
* @param string $where WHERE clause with placeholders.
|
|
* @param array<int, mixed> $params Placeholder values.
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private static function fetch( string $entity_type, string $table, string $id_col, string $where, array $params ): array {
|
|
global $wpdb;
|
|
$select = 'post' === $entity_type
|
|
? "SELECT t.*, p.post_type AS __post_type FROM `{$table}` t LEFT JOIN `{$wpdb->posts}` p ON p.ID = t.`{$id_col}`"
|
|
: "SELECT t.* FROM `{$table}` t";
|
|
|
|
return (array) $wpdb->get_results( $wpdb->prepare( "{$select} WHERE {$where}", ...$params ), ARRAY_A );
|
|
}
|
|
|
|
/**
|
|
* Compare the native meta of the keys this group owns with the flat rows,
|
|
* and (when $write) bring native in line according to $policy.
|
|
*
|
|
* @param string $entity_type Entity type.
|
|
* @param string $group Group the rows come from.
|
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
|
* @param array<int, array<string, mixed>> $rows Flat rows.
|
|
* @param string $policy 'overwrite' | 'only_missing'.
|
|
* @param bool $write False = classify only (dry run).
|
|
* @return array{written:int, missing:int, differs:int, same:int, default_skipped:int}
|
|
*/
|
|
private static function apply_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows, string $policy, bool $write ): array {
|
|
$counts = array(
|
|
'written' => 0,
|
|
'missing' => 0,
|
|
'differs' => 0,
|
|
'same' => 0,
|
|
'default_skipped' => 0,
|
|
);
|
|
list( $wanted, $fields ) = self::flat_values( $entity_type, $group, $adapter, $rows );
|
|
if ( empty( $wanted ) ) {
|
|
return $counts;
|
|
}
|
|
$native = self::native_values( $entity_type, $adapter, array_keys( $wanted ), array_keys( $fields ) );
|
|
|
|
$deletes = array();
|
|
$inserts = array();
|
|
foreach ( $wanted as $id => $values ) {
|
|
foreach ( $values as $key => $value ) {
|
|
$has = isset( $native[ $id ] ) && array_key_exists( $key, $native[ $id ] );
|
|
if ( null === $value ) {
|
|
if ( $has ) {
|
|
++$counts['differs'];
|
|
if ( 'overwrite' === $policy ) {
|
|
$deletes[ $key ][] = $id;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if ( ! $has ) {
|
|
if ( self::is_default( $value, $fields[ $key ] ) ) {
|
|
++$counts['default_skipped'];
|
|
continue;
|
|
}
|
|
++$counts['missing'];
|
|
$inserts[] = array( $id, $key, $value );
|
|
continue;
|
|
}
|
|
if ( self::same_value( $native[ $id ][ $key ], $value ) ) {
|
|
++$counts['same'];
|
|
continue;
|
|
}
|
|
++$counts['differs'];
|
|
if ( 'overwrite' === $policy ) {
|
|
$deletes[ $key ][] = $id;
|
|
$inserts[] = array( $id, $key, $value );
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( $write ) {
|
|
self::write_native( $entity_type, $adapter, $deletes, $inserts, array_keys( $wanted ) );
|
|
$counts['written'] = count( $inserts );
|
|
}
|
|
return $counts;
|
|
}
|
|
|
|
/**
|
|
* Values this group would write to native, per entity and key (NULL = none),
|
|
* restricted to the keys the group owns for that entity.
|
|
*
|
|
* @param string $entity_type Entity type.
|
|
* @param string $group Group name.
|
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
|
* @param array<int, array<string, mixed>> $rows Flat rows.
|
|
* @return array{0: array<int, array<string, string|null>>, 1: array<string, array<string, mixed>>}
|
|
*/
|
|
private static function flat_values( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): array {
|
|
$id_col = $adapter->get_entity_id_column();
|
|
$wanted = array();
|
|
$fields = array();
|
|
foreach ( $rows as $row ) {
|
|
$id = (int) $row[ $id_col ];
|
|
$post_type = 'post' === $entity_type ? (string) ( $row['__post_type'] ?? '' ) : '';
|
|
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
|
|
$owner = TMDO_Entity_Registry::get_field( $entity_type, $field['key'], $post_type );
|
|
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
|
if ( null === $owner || $owner['group'] !== $group || ! array_key_exists( $col, $row ) ) {
|
|
continue;
|
|
}
|
|
$fields[ $field['key'] ] = $field;
|
|
$wanted[ $id ][ $field['key'] ] = null === $row[ $col ]
|
|
? null
|
|
: (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) );
|
|
}
|
|
}
|
|
return array( $wanted, $fields );
|
|
}
|
|
|
|
/**
|
|
* First native value per entity and key.
|
|
*
|
|
* @param string $entity_type Entity type.
|
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
|
* @param int[] $ids Entity IDs.
|
|
* @param string[] $keys Meta keys.
|
|
* @return array<int, array<string, string>>
|
|
*/
|
|
private static function native_values( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $ids, array $keys ): array {
|
|
global $wpdb;
|
|
$meta_table = $adapter->get_native_meta_table();
|
|
$id_col = $adapter->get_entity_id_column();
|
|
$pk = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
|
|
$in_ids = implode( ',', array_map( 'intval', $ids ) );
|
|
$in_keys = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
|
$rows = (array) $wpdb->get_results(
|
|
$wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key, meta_value FROM `{$meta_table}` WHERE `{$id_col}` IN ({$in_ids}) AND meta_key IN ({$in_keys}) ORDER BY `{$pk}`", ...$keys ),
|
|
ARRAY_A
|
|
);
|
|
|
|
$native = array();
|
|
foreach ( $rows as $r ) {
|
|
if ( ! isset( $native[ (int) $r['id'] ][ $r['meta_key'] ] ) ) {
|
|
$native[ (int) $r['id'] ][ $r['meta_key'] ] = (string) $r['meta_value'];
|
|
}
|
|
}
|
|
return $native;
|
|
}
|
|
|
|
/**
|
|
* Delete / insert native rows and clear the entities' meta caches.
|
|
*
|
|
* @param string $entity_type Entity type.
|
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
|
* @param array<string, int[]> $deletes Meta key → entity IDs to clear first.
|
|
* @param array<int, array{0:int, 1:string, 2:string}> $inserts Rows to insert.
|
|
* @param int[] $ids Entities touched.
|
|
* @return void
|
|
*/
|
|
private static function write_native( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $deletes, array $inserts, array $ids ): void {
|
|
global $wpdb;
|
|
$meta_table = $adapter->get_native_meta_table();
|
|
$id_col = $adapter->get_entity_id_column();
|
|
|
|
foreach ( $deletes as $meta_key => $del_ids ) {
|
|
$in = implode( ',', array_map( 'intval', $del_ids ) );
|
|
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$meta_table}` WHERE meta_key = %s AND `{$id_col}` IN ({$in})", $meta_key ) );
|
|
}
|
|
if ( $inserts ) {
|
|
$values = implode( ', ', array_fill( 0, count( $inserts ), '(%d, %s, %s)' ) );
|
|
$wpdb->query( $wpdb->prepare( "INSERT INTO `{$meta_table}` (`{$id_col}`, meta_key, meta_value) VALUES {$values}", ...array_merge( ...$inserts ) ) );
|
|
}
|
|
foreach ( $ids as $id ) {
|
|
wp_cache_delete( $id, "{$entity_type}_meta" );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a stored value (as WP would store it) equals the field default.
|
|
*
|
|
* @param string $value Value as it would be stored.
|
|
* @param array<string, mixed> $field Field definition.
|
|
* @return bool
|
|
*/
|
|
private static function is_default( string $value, array $field ): bool {
|
|
return isset( $field['default'] ) && (string) maybe_serialize( $field['default'] ) === $value;
|
|
}
|
|
|
|
/**
|
|
* Native and candidate values are equal (numeric-aware: '3' equals '3.000000').
|
|
*
|
|
* @param string $native Native meta value.
|
|
* @param string $candidate Value from the flat table.
|
|
* @return bool
|
|
*/
|
|
private static function same_value( string $native, string $candidate ): bool {
|
|
return $native === $candidate
|
|
|| ( is_numeric( $native ) && is_numeric( $candidate ) && (float) $native === (float) $candidate );
|
|
}
|
|
}
|