feat(2meet-data-optimizer): write flat-table data back to native meta
- TMDO_Native_Restorer copies an entity's flat-table values into its native meta table so the value each key reads today is what native returns afterwards: the owning group (by post type for posts) wins, values are stored as WP stores them, NULL removes the native row - Resumable per-group id cursor, catch-up pass for rows updated during the run, optional demote to dual_write; CLI wp tmdo bridge-restore-native <entity> [--dry-run] [--batch=<n>] [--demote] - Verify: NativeRestorerTest (5, user) + NativeRestorerPostTest (2, scoped vs legacy routing); core unit 652 / integration 456, HP 148, WC 10 OK; scale estimate dev21 user ~5.1M / post ~4.0M flat rows - PR: PR-031
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
<?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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @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.
|
||||
* @return array{groups: array<string, array{rows:int, written:int}>, demoted: bool}
|
||||
*/
|
||||
public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false ): array {
|
||||
global $wpdb;
|
||||
|
||||
$report = array(
|
||||
'groups' => array(),
|
||||
'demoted' => false,
|
||||
);
|
||||
$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 );
|
||||
foreach ( $tables as $group => $table ) {
|
||||
$entry = array(
|
||||
'rows' => 0,
|
||||
'written' => 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 ];
|
||||
if ( ! $dry_run ) {
|
||||
$entry['written'] += self::write_rows( $entity_type, $group, $adapter, $rows );
|
||||
$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::write_rows( $entity_type, $group, $adapter, $rows );
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 $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 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the native meta of the keys this group owns for each row.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group Group the rows come from.
|
||||
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||
* @param array $rows Flat rows.
|
||||
* @return int Native values written.
|
||||
*/
|
||||
private static function write_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): int {
|
||||
global $wpdb;
|
||||
if ( empty( $rows ) ) {
|
||||
return 0;
|
||||
}
|
||||
$meta_table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
$deletes = array();
|
||||
$inserts = 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;
|
||||
}
|
||||
$deletes[ $field['key'] ][] = $id;
|
||||
if ( null !== $row[ $col ] ) {
|
||||
$inserts[] = array( $id, $field['key'], (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( $deletes as $meta_key => $ids ) {
|
||||
$in = implode( ',', array_map( 'intval', $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 ( array_unique( array_map( static fn( $row ) => (int) $row[ $id_col ], $rows ) ) as $id ) {
|
||||
wp_cache_delete( $id, "{$entity_type}_meta" );
|
||||
}
|
||||
return count( $inserts );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user