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:
2026-09-26 01:25:20 +08:00
parent f4eee2338b
commit aeca12b41d
9 changed files with 471 additions and 4 deletions
+1
View File
@@ -268,6 +268,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
+45
View File
@@ -303,6 +303,50 @@ class TMDO_CLI_Post {
WP_CLI::success( 'Post groups reconciled; tmdo_post_scoped_routing is on.' );
}
// ── post-status-sync ──────────────────────────────────────────────────────
/**
* Add post_status / post_date copies (and their index) to every post
* group table and fill them from wp_posts.
*
* Existing tables only get the columns here, never in a web request:
* ALTER on a large table can take seconds. Afterwards status changes keep
* the copies current. Re-run after bulk writes that bypass WordPress
* (e.g. post-reconcile-groups, post migrations).
*
* ## OPTIONS
*
* [--dry-run]
* : Count rows whose copies are missing or stale; change nothing.
*
* [--batch=<n>]
* : Rows per UPDATE. Default 1000.
*
* ## EXAMPLES
*
* wp tmdo post-status-sync --dry-run
* wp tmdo post-status-sync
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function post_status_sync( $args, $assoc_args ): void {
$dry_run = isset( $assoc_args['dry-run'] );
$report = TMDO_Post_Status_Sync::run( $dry_run, (int) ( $assoc_args['batch'] ?? 1000 ) );
if ( empty( $report ) ) {
WP_CLI::log( 'No post group tables.' );
}
foreach ( $report as $group => $r ) {
WP_CLI::log( sprintf( '%s: %d row(s), %d missing or stale%s', $group, $r['rows'], $r['mismatched'], $dry_run ? '' : sprintf( ' → %s', TMDO_Post_Status_Sync::state( $group ) ) ) );
}
if ( $dry_run ) {
WP_CLI::success( '[dry-run] Nothing changed.' );
return;
}
WP_CLI::success( 'post_status / post_date copies are in sync.' );
}
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
/**
@@ -883,3 +927,4 @@ WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_sh
WP_CLI::add_command( 'tmdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
WP_CLI::add_command( 'wpdo post-reconcile-groups', array( 'TMDO_CLI_Post', 'post_reconcile_groups' ) );
WP_CLI::add_command( 'tmdo post-reconcile-groups', array( 'TMDO_CLI_Post', 'post_reconcile_groups' ) );
WP_CLI::add_command( 'tmdo post-status-sync', array( 'TMDO_CLI_Post', 'post_status_sync' ) );
+1
View File
@@ -54,6 +54,7 @@ SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個
1. **原生 meta_query 在百萬規模下無法使用。** 任何以 meta 為條件的搜尋都要 14–17 秒,扁平化是必要的,不是錦上添花。
2. **扁平表的價值集中在「搜尋與篩選」。** 單筆或批次讀 meta(情境 4–6)三種形式都在 1 ms 內,差距可以忽略,而且實務上還有 object cache。
3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。
- **PR-028**:post 群組表可存 `post_status`、`post_date` 副本與 `(post_status, post_date)` 索引(`wp tmdo post-status-sync`)。1M 列實測:加欄位 0.05 秒(instant)、建索引 5.3 秒、分批(1,000 列)回填 32 秒,之後與 `posts` 0 筆不一致。欄位 collation 必須跟 `posts.post_status` 相同,否則比對會出現 illegal mix of collations。查詢改寫在 PR-029。
4. **Hot 表的索引規則有漏洞。** `add_covering_indexes()` 只替 decimal、tinyint,以及名稱以 `_time` 結尾的 bigint 建索引,所以 `hp_vendor` 沒有索引,情境 3 要 3.4 秒,比 Entity 慢約 12,000 倍。
5. **Entity 表只有單欄索引。** 情境 2 輸給有複合索引的 Hot 表(337 ms 對 277 ms)。
- **PR-027 之後**:群組可用 `register_group( ..., array( 'indexes' => ... ) )` 宣告複合索引。在 1M 列的 Entity 表加上 `cidx_verified_price (hp_verified, hp_price)` 花 8.1 秒;重跑情境 2(`--iterations=10`),Entity p50 從 337 ms 降到 **278 ms**(p95 285 ms),Hot 是 269 ms(p95 280 ms),差距在量測誤差內。執行計畫改走 `e:cidx_verified_price`。
+3
View File
@@ -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();
+6 -1
View File
@@ -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_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 );
}
}
+1
View File
@@ -644,6 +644,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
// Options Manager.
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
+173
View File
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* post_status / post_date copies on post entity tables (O-1 schema).
*
* Listing 1 (publish) has rows in both groups, draft 2 only in ps_a.
*/
class PostStatusSyncTest extends TestCase {
private const A = 'wp_itest_wpdo_post_ps_a';
private const B = 'wp_itest_wpdo_post_ps_b';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-cache-orchestrator.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/engine/class-tmdo-hook-bus.php',
'includes/engine/class-tmdo-post-status-sync.php',
) as $file ) {
require_once $base . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci" );
foreach ( array( self::A, self::B ) as $table ) {
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, k VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" );
}
$wpdb->query( "INSERT INTO `{$wpdb->posts}` VALUES (1, 'hp_listing', 'publish', '2026-01-01 10:00:00'), (2, 'hp_listing', 'draft', '2026-02-01 10:00:00'), (3, 'hp_listing', 'pending', '2026-03-01 10:00:00')" );
$wpdb->query( 'INSERT INTO `' . self::A . "` (post_id, k) VALUES (1, 'a1'), (2, 'a2')" );
$wpdb->query( 'INSERT INTO `' . self::B . "` (post_id, k) VALUES (1, 'b1')" );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cache'] = array();
$GLOBALS['_wp_posts'] = array();
$GLOBALS['_wp_current_filter'] = '';
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group( 'post', 'ps_a', array( array( 'key' => 'k', 'type' => 'text' ) ) );
WPDO_Entity_Registry::register_group( 'post', 'ps_b', array( array( 'key' => 'k2', 'type' => 'text' ) ) );
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$ref->setAccessible( true );
$ref->setValue( null, null );
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->posts, self::A, self::B ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
/** @return array<int, array{0:?string, 1:?string}> post_id → [status, date] */
private function copies( string $table ): array {
global $wpdb;
$out = array();
foreach ( (array) $wpdb->get_results( "SELECT post_id, post_status, post_date FROM `{$table}` ORDER BY post_id", ARRAY_A ) as $row ) {
$out[ (int) $row['post_id'] ] = array( $row['post_status'], $row['post_date'] );
}
return $out;
}
private function has_index( string $table ): bool {
global $wpdb;
return in_array( 'idx_post_status_date', array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ), true );
}
public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void {
$report = TMDO_Post_Status_Sync::run( false, 1 );
$this->assertSame(
array(
1 => array( 'publish', '2026-01-01 10:00:00' ),
2 => array( 'draft', '2026-02-01 10:00:00' ),
),
$this->copies( self::A )
);
$this->assertSame( array( 1 => array( 'publish', '2026-01-01 10:00:00' ) ), $this->copies( self::B ) );
$this->assertTrue( $this->has_index( self::A ) );
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_b' ) );
$this->assertSame( 0, TMDO_Post_Status_Sync::mismatches( 'ps_a' ) );
$this->assertSame( 2, $report['ps_a']['mismatched'] );
}
public function test_copies_use_the_posts_collation_so_they_compare_with_posts(): void {
global $wpdb;
TMDO_Post_Status_Sync::run();
$column = $wpdb->get_row( 'SHOW FULL COLUMNS FROM `' . self::A . "` LIKE 'post_status'", ARRAY_A );
$this->assertSame( 'utf8mb4_unicode_520_ci', $column['Collation'] );
$wpdb->query( 'UPDATE `' . self::A . "` SET post_status = 'stale' WHERE post_id = 2" );
$this->assertSame( 1, TMDO_Post_Status_Sync::mismatches( 'ps_a' ) );
}
public function test_dry_run_only_counts(): void {
global $wpdb;
$report = TMDO_Post_Status_Sync::run( true );
$this->assertSame( 2, $report['ps_a']['mismatched'] );
$this->assertSame( 1, $report['ps_b']['mismatched'] );
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_a' ) );
}
public function test_status_transition_updates_the_post_in_every_group_table(): void {
TMDO_Post_Status_Sync::run();
TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) );
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::A )[1] );
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::B )[1] );
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2], 'other posts untouched' );
}
public function test_transition_leaves_tables_without_the_columns_alone(): void {
global $wpdb;
$wpdb->last_error = '';
TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) );
$this->assertSame( '', (string) $wpdb->last_error );
}
public function test_new_empty_table_gets_the_columns_at_once_but_a_populated_one_waits_for_the_cli(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::B . '`' );
TMDO_Post_Status_Sync::on_schema_updated( 'post', 'ps_b', self::B );
TMDO_Post_Status_Sync::on_schema_updated( 'post', 'ps_a', self::A );
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_b' ) );
$this->assertTrue( $this->has_index( self::B ) );
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_a' ) );
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
}
public function test_flat_write_that_creates_a_row_fills_the_copies(): void {
TMDO_Post_Status_Sync::run();
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, array( 'post' => 'dual_write', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' );
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
WPDO_Hook_Bus::intercept_update( null, 3, 'k', 'a3', '' );
WPDO_Hook_Bus::intercept_update( null, 1, 'k', 'a1b', '' );
$this->assertSame( array( 'pending', '2026-03-01 10:00:00' ), $this->copies( self::A )[3] );
$this->assertSame( array( 'publish', '2026-01-01 10:00:00' ), $this->copies( self::A )[1], 'existing row keeps its synced copies' );
}
}
+2
View File
@@ -329,6 +329,7 @@ if ( ! function_exists( 'get_term_meta' ) ) { function get_term_meta( int $tid,
if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } }
if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } }
if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } }
if ( ! function_exists( 'get_post' ) ) { function get_post( $post_id ) { return $GLOBALS['_wp_posts'][ (int) $post_id ] ?? null; } }
if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
@@ -582,6 +583,7 @@ require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';