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
This commit is contained in:
2026-09-26 20:34:28 +08:00
parent 4fc59b757e
commit a7134bfb80
3 changed files with 77 additions and 2 deletions
+33 -2
View File
@@ -201,7 +201,8 @@ final class TMDO_Hook_Bus {
* (NULL when native has no row). Runs in dual_write / shadow_read only, * (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 * 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 * 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 string $type Entity type.
* @param int $object_id Entity ID. * @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 ); $before = self::maybe_read_before_value( $type, $object_id, $meta_key, $field_def, $op );
$native = self::native_first_value( $type, $object_id, $meta_key ); $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 ) { if ( 'delete' === $op ) {
do_action( 'wpdo_after_delete', $type, $object_id, $meta_key, $meta_value, $result, $delete_all, $before ); 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'; 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<string, mixed> $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 操作 * 執行 Upsert 操作
*/ */
@@ -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. * Run $fn with a native usermeta table, dropped afterwards.
*/ */
+26
View File
@@ -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" );
}
}
}
);