` 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, 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 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> */ 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> $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> $rows Flat rows. * @return array{0: array>, 1: array>} */ 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> */ 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 $deletes Meta key → entity IDs to clear first. * @param array $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 $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 ); } }