5968bcd95d
- on_transition() skips a recorded group whose table no longer exists and removes it from tmdo_post_status_columns; every post status change used to UPDATE the missing table and print a database error (seen on dev36 after a throwaway group's table was dropped) - Verify: PostStatusSyncTest (dropped table), integration 578, unit 701, phpcs, stan - PR: PR-106
255 lines
8.8 KiB
PHP
255 lines
8.8 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';
|
|
|
|
/**
|
|
* 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 ) ) {
|
|
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`)';
|
|
}
|
|
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 );
|
|
}
|
|
}
|