b917e45c8c
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 14s
Tests / PHP Lint (pull_request) Successful in 1m0s
Tests / PHPCS (pull_request) Successful in 1m15s
Tests / Unit Tests (pull_request) Successful in 57s
Tests / Integration Tests (pull_request) Successful in 1m14s
Tests / PHPStan (pull_request) Successful in 34s
- CI PHPStan (level 6, baseline for pre-existing debt) failed on 35 errors in
code added on hardening/2.0: missing iterable value types (PHPDoc only), a
redundant method_exists() in uninstall.php, and the new WP-CLI commands
registered as array('Class', 'instanceMethod') — now array(new Class(), ...)
- Local runs had been masked by a PHPStan tmpDir shared with the act CI runner
(cached container pointing at a removed phar); use a private tmpDir
- Verify: phpstan (private tmpDir) no errors, phpcs clean on changed files,
phpunit Unit 658 OK; dev22 (running this branch) CLI post-status-sync /
bridge-restore-native dry runs OK
222 lines
8.0 KiB
PHP
222 lines
8.0 KiB
PHP
<?php
|
|
/**
|
|
* TMDO_Post_Group_Reconciler — move post values written under legacy routing.
|
|
*
|
|
* Before post_type-scoped routing (tmdo_post_scoped_routing), a meta key
|
|
* shared by several post groups was read and written through the last group
|
|
* registered, e.g. a listing's hp_rating lived in wp_wpdo_post_hp_vendor_core.
|
|
* Scoped lookups read it from the listing's own group instead, so those
|
|
* values have to be copied there first.
|
|
*
|
|
* Rule: after the switch every read must return what it returned before.
|
|
* - A non-NULL value in the legacy owner's table is what reads served, so
|
|
* it overwrites the scoped group's column.
|
|
* - NULL / missing legacy values leave the scoped column alone (it holds
|
|
* the backfill copy, the only data there is).
|
|
* - Source rows are left in place; scoped lookups never read them again.
|
|
*
|
|
* Batched by entity id with a cursor option so an interrupted run resumes; a
|
|
* final pass re-copies legacy rows updated since the first run started, then
|
|
* scoped routing is switched 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 -- placeholders are built for spread params; table/column names come from the entity registry via Schema_Manager and are sanitized there.
|
|
|
|
/**
|
|
* Copies legacy-routed post values into their post type's group table.
|
|
*/
|
|
final class TMDO_Post_Group_Reconciler {
|
|
|
|
/** Option: move id → last entity id copied. */
|
|
public const CURSOR_OPTION = 'tmdo_post_reconcile_cursor';
|
|
|
|
/** Option: DB time the first (possibly interrupted) run started. */
|
|
public const STARTED_OPTION = 'tmdo_post_reconcile_started';
|
|
|
|
/**
|
|
* Keys of scoped post groups whose legacy owner is another group.
|
|
*
|
|
* @return array<int, array{target:string, source:string, key:string, post_types:string[]}>
|
|
*/
|
|
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<int, array<string, mixed>>, 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<string, mixed> $move One entry of plan().
|
|
* @param string $id_col Entity id column of the flat tables.
|
|
* @return array{0:string, 1:array<int, mixed>}
|
|
*/
|
|
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<string, mixed> $move One entry of plan().
|
|
* @param string $id_col Entity id column of the flat tables.
|
|
* @param array<int, array<string, mixed>> $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 );
|
|
}
|
|
}
|