feat(2meet-data-optimizer): store post_status and post_date copies in post entity tables
- TMDO_Post_Status_Sync: post group tables get post_status / post_date
columns (posts' collation) and an (post_status, post_date) index.
Brand-new empty tables get them on creation; existing tables only via
`wp tmdo post-status-sync [--dry-run] [--batch]`, never as DDL on a
populated table in a web request. Per-group state in
tmdo_post_status_columns ('columns' | 'ready').
- transition_post_status refreshes the copies; Hook Bus upserts that
create a post row fill them in (existing rows keep theirs).
- Bench (1M rows): add columns 0.05 s, index 5.3 s, batched backfill 32 s,
0 mismatches afterwards.
- Verify: core unit 659 / integration 466 green, phpcs clean
- PR: PR-028
This commit is contained in:
@@ -109,6 +109,9 @@ class TMDO_Core {
|
||||
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
|
||||
TMDO_Hook_Bus_Bridge::maybe_init_hook_bus();
|
||||
|
||||
// Keep post_status / post_date copies on post entity tables current.
|
||||
TMDO_Post_Status_Sync::register();
|
||||
|
||||
// Register audit log listeners for wpdo_after_write / wpdo_after_delete.
|
||||
if ( class_exists( 'TMDO_Audit_Logger' ) ) {
|
||||
TMDO_Audit_Logger::init();
|
||||
|
||||
@@ -328,15 +328,20 @@ final class TMDO_Hook_Bus {
|
||||
|
||||
try {
|
||||
// 單一 upsert,避免 SELECT→UPDATE/INSERT 的 read-modify-write race。
|
||||
// post:新列一併帶入 post_status / post_date 副本(只在 insert 時寫入)。
|
||||
$extra = 'post' === $type ? TMDO_Post_Status_Sync::insert_columns( $entity_id, $group ) : array();
|
||||
$result = TMDO_DB::upsert(
|
||||
$table,
|
||||
array(
|
||||
$id_col => $entity_id,
|
||||
$col => $db_value,
|
||||
array_merge(
|
||||
array(
|
||||
$id_col => $entity_id,
|
||||
$col => $db_value,
|
||||
),
|
||||
$extra
|
||||
),
|
||||
array( $col ),
|
||||
$id_col,
|
||||
array( '%d', $format )
|
||||
array_merge( array( '%d', $format ), array_fill( 0, count( $extra ), '%s' ) )
|
||||
);
|
||||
|
||||
// 清除快取
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<?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 );
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user