8ee3dce0fe
- Copy route: drop WordPress's GROUP BY when the flat row is the only join, and read posts.ID tie-breaks as the flat post_id (own direction, once) - Post status sync: index (post_status, post_date, post_id) replaces the two-column idx_post_status_date (added / dropped by post-status-sync) - dev36, 235k of 1.3M listings in a price range: main query 1.7 s -> 3 ms - Verify: composer test (704), test:integration (597), phpcs, stan - PR: PR-128
263 lines
9.2 KiB
PHP
263 lines
9.2 KiB
PHP
<?php
|
||
/**
|
||
* Copies of posts.post_status / post_date on post entity tables (O-1).
|
||
*
|
||
* With the copies (and their `(post_status, post_date)` index) a routed search
|
||
* can filter and sort inside the flat table instead of joining back to posts.
|
||
*
|
||
* Columns only reach a table two ways, never as DDL on a populated table in a
|
||
* web request:
|
||
* - a brand-new (empty) group table gets them when it is created;
|
||
* - `wp tmdo post-status-sync` adds them to existing tables and backfills.
|
||
*
|
||
* Per-group state lives in the TMDO_Post_Status_Sync::OPTION option:
|
||
* 'columns' (added, backfill pending) or 'ready' (every row copied).
|
||
* Afterwards transition_post_status keeps the copies current, and flat writes
|
||
* that create a row fill them in.
|
||
*
|
||
* @package TMDO
|
||
*/
|
||
|
||
if ( ! defined( 'ABSPATH' ) ) {
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* Keeps post_status / post_date copies on post entity tables.
|
||
*/
|
||
final class TMDO_Post_Status_Sync {
|
||
|
||
public const OPTION = 'tmdo_post_status_columns';
|
||
|
||
private const INDEX = 'idx_post_status_date_id';
|
||
|
||
/** The 2.2–2.3 (post_status, post_date) index, replaced by INDEX. */
|
||
private const LEGACY_INDEX = 'idx_post_status_date';
|
||
|
||
/**
|
||
* Hook the sync into WordPress.
|
||
*
|
||
* @return void
|
||
*/
|
||
public static function register(): void {
|
||
add_action( 'transition_post_status', array( __CLASS__, 'on_transition' ), 10, 3 );
|
||
add_action( 'wpdo_schema_updated', array( __CLASS__, 'on_schema_updated' ), 10, 3 );
|
||
}
|
||
|
||
/**
|
||
* '' (no columns), 'columns' or 'ready'.
|
||
*
|
||
* @param string $group Post group name.
|
||
* @return string
|
||
*/
|
||
public static function state( string $group ): string {
|
||
$states = get_option( self::OPTION, array() );
|
||
return is_array( $states ) ? (string) ( $states[ $group ] ?? '' ) : '';
|
||
}
|
||
|
||
/**
|
||
* Every post status change or save: refresh the copies in each table that has them.
|
||
*
|
||
* @param string $new_status New status.
|
||
* @param string $old_status Old status.
|
||
* @param object $post Saved post.
|
||
* @return void
|
||
*/
|
||
public static function on_transition( $new_status, $old_status, $post ): void {
|
||
global $wpdb;
|
||
unset( $new_status, $old_status );
|
||
foreach ( self::groups_with_columns() as $group ) {
|
||
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||
self::forget( $group ); // The table was dropped (retired or uninstalled group).
|
||
continue;
|
||
}
|
||
$wpdb->query(
|
||
$wpdb->prepare(
|
||
"UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||
$post->post_status,
|
||
$post->post_date,
|
||
$post->ID
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A post group table was just created or altered: an empty one gets the
|
||
* columns now; one holding rows waits for the CLI.
|
||
*
|
||
* @param string $entity_type Entity type.
|
||
* @param string $group Group name.
|
||
* @param string $table Table name.
|
||
* @return void
|
||
*/
|
||
public static function on_schema_updated( $entity_type, $group, $table ): void {
|
||
global $wpdb;
|
||
if ( 'post' !== $entity_type || '' !== self::state( $group ) ) {
|
||
return;
|
||
}
|
||
if ( null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||
return;
|
||
}
|
||
if ( self::add_columns( $group ) ) {
|
||
self::set_state( $group, 'ready' );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Copies for a row a flat write is about to create (empty when the
|
||
* group's table has no such columns).
|
||
*
|
||
* @param int $post_id Post ID.
|
||
* @param string $group Group name.
|
||
* @return array{post_status?:string, post_date?:string}
|
||
*/
|
||
public static function insert_columns( int $post_id, string $group ): array {
|
||
if ( '' === self::state( $group ) ) {
|
||
return array();
|
||
}
|
||
$post = get_post( $post_id );
|
||
if ( ! $post ) {
|
||
return array();
|
||
}
|
||
return array(
|
||
'post_status' => $post->post_status,
|
||
'post_date' => $post->post_date,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Add the columns to every post group table and copy posts' values in.
|
||
*
|
||
* @param bool $dry_run Count mismatching rows only.
|
||
* @param int $batch Rows per UPDATE.
|
||
* @return array<string, array{rows:int, mismatched:int}>
|
||
*/
|
||
public static function run( bool $dry_run = false, int $batch = 1000 ): array {
|
||
global $wpdb;
|
||
$batch = max( 1, $batch );
|
||
$report = array();
|
||
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||
continue;
|
||
}
|
||
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||
$report[ $group ] = array(
|
||
'rows' => $rows,
|
||
'mismatched' => '' === self::state( $group ) ? $rows : self::mismatches( $group ),
|
||
);
|
||
if ( $dry_run || ! self::add_columns( $group ) ) {
|
||
continue;
|
||
}
|
||
$max = (int) $wpdb->get_var( "SELECT MAX(id) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||
for ( $from = 0; $from < $max; $from += $batch ) {
|
||
$wpdb->query(
|
||
$wpdb->prepare(
|
||
"UPDATE `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id SET t.post_status = p.post_status, t.post_date = p.post_date WHERE t.id > %d AND t.id <= %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||
$from,
|
||
$from + $batch
|
||
)
|
||
);
|
||
}
|
||
self::set_state( $group, 'ready' );
|
||
}
|
||
return $report;
|
||
}
|
||
|
||
/**
|
||
* Rows whose copies differ from posts (rows of deleted posts excluded).
|
||
*
|
||
* @param string $group Group name.
|
||
* @return int
|
||
*/
|
||
public static function mismatches( string $group ): int {
|
||
global $wpdb;
|
||
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||
return (int) $wpdb->get_var(
|
||
"SELECT COUNT(*) FROM `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id WHERE NOT ( t.post_status <=> p.post_status ) OR NOT ( t.post_date <=> p.post_date )" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Add whichever of the two columns and the index the table lacks.
|
||
*
|
||
* @param string $group Group name.
|
||
* @return bool False when the table is missing or the ALTER failed.
|
||
*/
|
||
private static function add_columns( string $group ): bool {
|
||
global $wpdb;
|
||
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||
return false;
|
||
}
|
||
$columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||
$indexes = array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||
$changes = array();
|
||
if ( ! in_array( 'post_status', $columns, true ) ) {
|
||
// Same collation as posts.post_status: the table default may differ,
|
||
// and comparing the two columns would then fail (illegal mix of collations).
|
||
$source = $wpdb->get_row( "SHOW FULL COLUMNS FROM `{$wpdb->posts}` LIKE 'post_status'", ARRAY_A );
|
||
$collation = preg_replace( '/[^A-Za-z0-9_]/', '', (string) ( $source['Collation'] ?? '' ) );
|
||
$changes[] = 'ADD COLUMN `post_status` VARCHAR(20)' . ( '' !== $collation ? " COLLATE {$collation}" : '' ) . ' DEFAULT NULL';
|
||
}
|
||
if ( ! in_array( 'post_date', $columns, true ) ) {
|
||
$changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL';
|
||
}
|
||
if ( ! in_array( self::INDEX, $indexes, true ) ) {
|
||
// Ends with post_id: the router reads the date route's ID tie-break
|
||
// from it, so MySQL walks the index and stops at the LIMIT (2.4 PR-128).
|
||
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`, `post_id`)';
|
||
}
|
||
if ( in_array( self::LEGACY_INDEX, $indexes, true ) ) {
|
||
$changes[] = 'DROP INDEX `' . self::LEGACY_INDEX . '`';
|
||
}
|
||
if ( $changes && false === $wpdb->query( "ALTER TABLE `{$table}` " . implode( ', ', $changes ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fixed DDL on a registry table name.
|
||
return false;
|
||
}
|
||
if ( '' === self::state( $group ) ) {
|
||
self::set_state( $group, 'columns' );
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Post groups whose table has the columns.
|
||
*
|
||
* @return string[]
|
||
*/
|
||
private static function groups_with_columns(): array {
|
||
$states = get_option( self::OPTION, array() );
|
||
return is_array( $states ) ? array_keys( $states ) : array();
|
||
}
|
||
|
||
/**
|
||
* Drop a group from the recorded states.
|
||
*
|
||
* @param string $group Group name.
|
||
* @return void
|
||
*/
|
||
private static function forget( string $group ): void {
|
||
$states = get_option( self::OPTION, array() );
|
||
if ( is_array( $states ) && isset( $states[ $group ] ) ) {
|
||
unset( $states[ $group ] );
|
||
update_option( self::OPTION, $states, true );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Record a group's state.
|
||
*
|
||
* @param string $group Group name.
|
||
* @param string $state 'columns' | 'ready'.
|
||
* @return void
|
||
*/
|
||
private static function set_state( string $group, string $state ): void {
|
||
$states = get_option( self::OPTION, array() );
|
||
$states = is_array( $states ) ? $states : array();
|
||
$states[ $group ] = $state;
|
||
update_option( self::OPTION, $states, true );
|
||
}
|
||
}
|