*/ public static function plan(): array { $moves = array(); foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) { $post_types = TMDO_Entity_Registry::get_group_post_types( 'post', $group ); if ( empty( $post_types ) ) { continue; } foreach ( TMDO_Entity_Registry::get_group_keys( 'post', $group ) as $key ) { $legacy = TMDO_Entity_Registry::get_field( 'post', $key ); if ( null === $legacy || $legacy['group'] === $group ) { continue; } $moves[] = array( 'target' => $group, 'source' => $legacy['group'], 'key' => $key, 'post_types' => $post_types, ); } } return $moves; } /** * Copy the values (or, with $dry_run, count what would be copied). * * @param bool $dry_run Count only; write nothing. * @param int $batch Rows per batch. * @return array{moves: array>, enabled: bool} */ public static function run( bool $dry_run = false, int $batch = 500 ): array { global $wpdb; $report = array( 'moves' => array(), 'enabled' => false, ); $adapter = TMDO_Entity_Registry::get_adapter( 'post' ); if ( ! $adapter ) { return $report; } $id_col = $adapter->get_entity_id_column(); $batch = max( 1, $batch ); if ( ! $dry_run && false === get_option( self::STARTED_OPTION ) ) { update_option( self::STARTED_OPTION, (string) $wpdb->get_var( 'SELECT NOW()' ), false ); } $cursor = get_option( self::CURSOR_OPTION, array() ); $cursor = is_array( $cursor ) ? $cursor : array(); $moves = self::plan(); foreach ( $moves as $move ) { list( $from, $params ) = self::source_clause( $move, $id_col ); $entry = array( 'target' => $move['target'], 'source' => $move['source'], 'key' => $move['key'], 'copied' => 0, 'overwritten' => 0, ); if ( $dry_run ) { $target = TMDO_Schema_Manager::get_table_name( 'post', $move['target'] ); $col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] ); $entry['copied'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) {$from}", ...$params ) ); $entry['overwritten'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) {$from} AND EXISTS ( SELECT 1 FROM `{$target}` t WHERE t.`{$id_col}` = s.`{$id_col}` AND t.`{$col}` IS NOT NULL AND t.`{$col}` <> s.`{$col}` )", ...$params ) ); $report['moves'][] = $entry; continue; } $move_id = "{$move['source']}>{$move['target']}:{$move['key']}"; $last = (int) ( $cursor[ $move_id ] ?? 0 ); do { $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT s.`{$id_col}` AS id, s.`" . TMDO_Schema_Manager::sanitize_column_name( $move['key'] ) . "` AS v {$from} AND s.`{$id_col}` > %d ORDER BY s.`{$id_col}` LIMIT %d", ...array_merge( $params, array( $last, $batch ) ) ), ARRAY_A ); $entry['copied'] += self::copy_rows( $move, $id_col, $rows ); $fetched = count( $rows ); if ( $fetched ) { $last = (int) end( $rows )['id']; $cursor[ $move_id ] = $last; update_option( self::CURSOR_OPTION, $cursor, false ); } } while ( $fetched === $batch ); $report['moves'][] = $entry; } if ( $dry_run ) { return $report; } // Catch-up: legacy-routed writes that landed after a batch passed their row. $started = (string) get_option( self::STARTED_OPTION ); foreach ( $moves as $move ) { list( $from, $params ) = self::source_clause( $move, $id_col ); $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT s.`{$id_col}` AS id, s.`" . TMDO_Schema_Manager::sanitize_column_name( $move['key'] ) . "` AS v {$from} AND s.updated_at >= %s", ...array_merge( $params, array( $started ) ) ), ARRAY_A ); self::copy_rows( $move, $id_col, $rows ); } update_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, '1', true ); delete_option( self::CURSOR_OPTION ); delete_option( self::STARTED_OPTION ); $report['enabled'] = true; return $report; } /** * FROM/WHERE for non-NULL legacy values of the move's post types. * * @param array $move One entry of plan(). * @param string $id_col Entity id column of the flat tables. * @return array{0:string, 1:array} */ private static function source_clause( array $move, string $id_col ): array { global $wpdb; $source = TMDO_Schema_Manager::get_table_name( 'post', $move['source'] ); $col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] ); $types = implode( ',', array_fill( 0, count( $move['post_types'] ), '%s' ) ); return array( "FROM `{$source}` s INNER JOIN `{$wpdb->posts}` p ON p.ID = s.`{$id_col}` AND p.post_type IN ({$types}) WHERE s.`{$col}` IS NOT NULL", $move['post_types'], ); } /** * Upsert one column of the target group rows and drop their cached copies. * * @param array $move One entry of plan(). * @param string $id_col Entity id column of the flat tables. * @param array> $rows Source rows: list of { id, v }. * @return int Rows written. */ private static function copy_rows( array $move, string $id_col, array $rows ): int { global $wpdb; if ( empty( $rows ) ) { return 0; } $target = TMDO_Schema_Manager::get_table_name( 'post', $move['target'] ); $col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] ); $values = array(); $params = array(); foreach ( $rows as $row ) { $values[] = '(%d, %s)'; $params[] = (int) $row['id']; $params[] = (string) $row['v']; } $wpdb->query( $wpdb->prepare( "INSERT INTO `{$target}` (`{$id_col}`, `{$col}`) VALUES " . implode( ', ', $values ) . " ON DUPLICATE KEY UPDATE `{$col}` = VALUES(`{$col}`)", ...$params ) ); if ( class_exists( 'TMDO_Cache_Orchestrator' ) ) { foreach ( $rows as $row ) { TMDO_Cache_Orchestrator::invalidate( 'post', (int) $row['id'], $move['target'] ); } } return count( $rows ); } }