From aeca12b41de4caf10c1acf1c326d58e71997b07d Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 26 Sep 2026 01:25:20 +0800 Subject: [PATCH] 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 --- 2meet-data-optimizer.php | 1 + cli/class-tmdo-cli-post.php | 45 ++++ docs/BENCHMARK-SCALE.md | 1 + includes/class-tmdo-core.php | 3 + includes/engine/class-tmdo-hook-bus.php | 13 +- .../engine/class-tmdo-post-status-sync.php | 236 ++++++++++++++++++ tests/bootstrap.php | 1 + tests/integration/PostStatusSyncTest.php | 173 +++++++++++++ tests/integration/bootstrap.php | 2 + 9 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 includes/engine/class-tmdo-post-status-sync.php create mode 100644 tests/integration/PostStatusSyncTest.php diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index 1e78b10..d6d1a18 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -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'; diff --git a/cli/class-tmdo-cli-post.php b/cli/class-tmdo-cli-post.php index fcfdc4f..75206fe 100644 --- a/cli/class-tmdo-cli-post.php +++ b/cli/class-tmdo-cli-post.php @@ -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=] + * : 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' ) ); diff --git a/docs/BENCHMARK-SCALE.md b/docs/BENCHMARK-SCALE.md index 9230b06..d522e1b 100644 --- a/docs/BENCHMARK-SCALE.md +++ b/docs/BENCHMARK-SCALE.md @@ -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`。 diff --git a/includes/class-tmdo-core.php b/includes/class-tmdo-core.php index cc53a12..e661487 100644 --- a/includes/class-tmdo-core.php +++ b/includes/class-tmdo-core.php @@ -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(); diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index 1f2f9d0..304d02f 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -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' ) ) ); // 清除快取 diff --git a/includes/engine/class-tmdo-post-status-sync.php b/includes/engine/class-tmdo-post-status-sync.php new file mode 100644 index 0000000..03743a5 --- /dev/null +++ b/includes/engine/class-tmdo-post-status-sync.php @@ -0,0 +1,236 @@ +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 + */ + 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 ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ccef6bc..8bb4bda 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -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'; diff --git a/tests/integration/PostStatusSyncTest.php b/tests/integration/PostStatusSyncTest.php new file mode 100644 index 0000000..f4f08f7 --- /dev/null +++ b/tests/integration/PostStatusSyncTest.php @@ -0,0 +1,173 @@ +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 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' ); + } +} diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 040868c..ea55d9d 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -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';