From a7134bfb80bee91e13f8a9b04998697b083b815c Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 26 Sep 2026 20:34:28 +0800 Subject: [PATCH] fix(2meet-data-optimizer): do not recreate a deleted entity's flat row - Deleting a post / user / term / comment drops its flat row in the adapter delete hook, then WordPress deletes each meta row; sync_from_native upserted NULL for each and brought the row back (512 orphan rows on dev36). With no native value it now only clears the column on an existing row - Verify: tests/live/run.php resync (25/25, entity deletes in dual_write and shadow_read leave no flat row); mut.php T8 flat_row NO-ROW; integration 516 - PR: PR-057b --- includes/engine/class-tmdo-hook-bus.php | 35 ++++++++++++++++++-- tests/integration/HookBusIntegrationTest.php | 18 ++++++++++ tests/live/suites/resync.php | 26 +++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index 02de79a..7b0d4ea 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -201,7 +201,8 @@ final class TMDO_Hook_Bus { * (NULL when native has no row). Runs in dual_write / shadow_read only, * after WP wrote native, so flat always ends up equal to native — also * when WP skipped or refused the write, and when an earlier direct SQL - * write had left flat stale. + * write had left flat stale. A NULL never creates a row: deleting an + * entity drops its flat row before WP deletes the meta rows one by one. * * @param string $type Entity type. * @param int $object_id Entity ID. @@ -222,7 +223,9 @@ final class TMDO_Hook_Bus { $before = self::maybe_read_before_value( $type, $object_id, $meta_key, $field_def, $op ); $native = self::native_first_value( $type, $object_id, $meta_key ); - $result = self::perform_upsert( $type, $object_id, $meta_key, null === $native ? null : maybe_unserialize( $native ), $field_def ); + $result = null === $native + ? self::clear_column( $type, $object_id, $meta_key, $field_def ) + : self::perform_upsert( $type, $object_id, $meta_key, maybe_unserialize( $native ), $field_def ); if ( 'delete' === $op ) { do_action( 'wpdo_after_delete', $type, $object_id, $meta_key, $meta_value, $result, $delete_all, $before ); @@ -522,6 +525,34 @@ final class TMDO_Hook_Bus { return 'flat'; } + /** + * Set a flat column to NULL on the entity's existing row; never insert one. + * + * @param string $type Entity type. + * @param int $entity_id Entity ID. + * @param string $meta_key Meta key. + * @param array $field_def Registered field. + * @return bool|null Null when there is no adapter or table. + */ + private static function clear_column( string $type, int $entity_id, string $meta_key, array $field_def ): ?bool { + global $wpdb; + + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + $table = TMDO_Schema_Manager::get_table_name( $type, $field_def['group'] ); + if ( ! $adapter || ! TMDO_Schema_Manager::table_exists( $table ) ) { + return null; + } + + $col = TMDO_Schema_Manager::sanitize_column_name( $meta_key ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->update( $table, array( $col => null ), array( $adapter->get_entity_id_column() => $entity_id ) ); + + TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $field_def['group'] ); + self::touch_posts_last_changed( $type ); + + return false !== $result; + } + /** * 執行 Upsert 操作 */ diff --git a/tests/integration/HookBusIntegrationTest.php b/tests/integration/HookBusIntegrationTest.php index fd66c4a..b4e4fec 100644 --- a/tests/integration/HookBusIntegrationTest.php +++ b/tests/integration/HookBusIntegrationTest.php @@ -256,6 +256,24 @@ class HookBusIntegrationTest extends TestCase { ); } + /** + * Deleting an entity drops its flat row first (adapter delete hook), then + * WordPress deletes each meta row; syncing those deletions must not bring + * the row back as all NULL. + */ + public function test_sync_of_a_deleted_key_does_not_recreate_a_missing_row(): void { + global $wpdb; + self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE ); + + $this->with_usermeta( + function () use ( $wpdb ) { + WPDO_Hook_Bus::sync_from_native( 'user', 31, 'first_name', 'Gone', 'delete' ); + + $this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '` WHERE user_id = 31' ) ); + } + ); + } + /** * Run $fn with a native usermeta table, dropped afterwards. */ diff --git a/tests/live/suites/resync.php b/tests/live/suites/resync.php index 649b815..75a0657 100644 --- a/tests/live/suites/resync.php +++ b/tests/live/suites/resync.php @@ -110,3 +110,29 @@ foreach ( array( 'dual_write', 'shadow_read' ) as $tmdo_live_mode ) { ); } } + +TMDO_Live::test( + 'deleting an entity leaves no flat row behind (dual_write and shadow_read)', + static function () { + require_once ABSPATH . 'wp-admin/includes/user.php'; + $deleters = array( + 'user' => static fn( int $id ) => wp_delete_user( $id ), + 'post' => static fn( int $id ) => wp_delete_post( $id, true ), + 'term' => static fn( int $id ) => wp_delete_term( $id, 'hp_listing_category' ), + 'comment' => static fn( int $id ) => wp_delete_comment( $id, true ), + ); + foreach ( array( 'dual_write', 'shadow_read' ) as $mode ) { + foreach ( tmdo_live_resync_cases() as $label => $case ) { + list( $type, $key, $make, $pt ) = $case; + TMDO_Live_Fixtures::field( $type, $key, $pt ); + TMDO_Live_Fixtures::use_modes( array( $type => $mode ) ); + $id = $make(); + update_metadata( $type, $id, $key, '41' ); + + $deleters[ $type ]( $id ); + + tmdo_live_assert_same( 'NO-ROW', TMDO_Live_Fixtures::flat( $type, $id, $key, $pt ), "{$mode} {$label}: flat row after delete" ); + } + } + } +);