fix(2meet-data-optimizer): resync flat rows from native meta after each write
- dual_write / shadow_read no longer write flat in the update_/add_/
delete_{type}_metadata pre-filters, before WP decides whether native
changes at all. added_/updated_/deleted_{type}_meta (priority 1) call
TMDO_Hook_Bus::sync_from_native(), which copies the entity's first native
row (plain SQL, NULL when gone) into flat. Flat now follows native when WP
skips (same value), refuses (non-matching prev_value, add $unique on an
existing key) or deletes (also delete_all: touched IDs are collected from
the meta IDs in delete_{type}_meta), and a flat value left stale by a
direct SQL write heals on the next write.
- update_metadata_by_mid key changes: the old key is remembered in
update_{type}_metadata_by_mid and re-synced too.
- wpdo_after_write / wpdo_after_delete fire from the sync with the same
arguments; aeav_only keeps the pre-filter short-circuit unchanged.
- $internal_ops lock key includes the entity type (user 5 ≠ post 5).
- Live suite resync: 4 entities × 2 modes × 3 scenarios (old code: 16 of
24 FAIL); integration: pre-filter leaves flat to the sync, delete_all
re-syncs every touched user.
- Verify: live resync + shadow_write + serialized + zero_side_effect 33/33
PASS; unit 687 OK, integration 485 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-057
This commit is contained in:
@@ -202,14 +202,71 @@ class HookBusIntegrationTest extends TestCase {
|
||||
$this->assertTrue( $result, 'aeav_only must return true to short-circuit EAV write' );
|
||||
}
|
||||
|
||||
public function test_update_writes_flat_and_returns_null_in_dual_write_mode(): void {
|
||||
/**
|
||||
* dual_write: the pre-filter only lets WP write native; flat is synced
|
||||
* from native afterwards (updated_/added_ actions → sync_from_native).
|
||||
*/
|
||||
public function test_update_leaves_flat_to_the_native_sync_in_dual_write_mode(): void {
|
||||
global $wpdb;
|
||||
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
|
||||
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
|
||||
|
||||
$result = WPDO_Hook_Bus::intercept_update( null, 3, 'first_name', 'Carol', '' );
|
||||
|
||||
$this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) );
|
||||
$this->assertNull( $result, 'dual_write must return null so WP continues to write EAV' );
|
||||
$this->assertNull( $this->read_flat_first_name( 3 ), 'no flat write before native is written' );
|
||||
|
||||
$this->with_usermeta(
|
||||
function () use ( $wpdb ) {
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 3, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ) );
|
||||
|
||||
WPDO_Hook_Bus::sync_from_native( 'user', 3, 'first_name', 'Carol', 'update' );
|
||||
|
||||
$this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* delete_all passes object ID 0: the delete_ action records the touched
|
||||
* users from the meta IDs, the deleted_ action re-syncs each to NULL.
|
||||
*/
|
||||
public function test_delete_all_resyncs_every_touched_entity_in_dual_write_mode(): void {
|
||||
global $wpdb;
|
||||
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
|
||||
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 21, 'first_name' => 'A' ) );
|
||||
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 22, 'first_name' => 'B' ) );
|
||||
|
||||
$this->with_usermeta(
|
||||
function () use ( $wpdb ) {
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 21, 'meta_key' => 'first_name', 'meta_value' => 'A' ) );
|
||||
$mid_a = (int) $wpdb->insert_id;
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 22, 'meta_key' => 'first_name', 'meta_value' => 'B' ) );
|
||||
$mid_b = (int) $wpdb->insert_id;
|
||||
|
||||
$GLOBALS['_wp_current_filter'] = 'delete_user_meta';
|
||||
WPDO_Hook_Bus::before_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' );
|
||||
$wpdb->query( "DELETE FROM `{$wpdb->usermeta}` WHERE meta_key = 'first_name'" );
|
||||
$GLOBALS['_wp_current_filter'] = 'deleted_user_meta';
|
||||
WPDO_Hook_Bus::on_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' );
|
||||
|
||||
$this->assertNull( $this->read_flat_first_name( 21 ) );
|
||||
$this->assertNull( $this->read_flat_first_name( 22 ) );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run $fn with a native usermeta table, dropped afterwards.
|
||||
*/
|
||||
private function with_usermeta( callable $fn ): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id) ) ENGINE=InnoDB" );
|
||||
try {
|
||||
$fn();
|
||||
} finally {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_update_passthrough_for_unregistered_key(): void {
|
||||
|
||||
@@ -160,7 +160,8 @@ class PostStatusSyncTest extends TestCase {
|
||||
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' ) );
|
||||
// aeav_only: the pre-filter itself writes flat (dual_write syncs from native after the write).
|
||||
$mode->setValue( null, array( 'post' => 'aeav_only', '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';
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test:
|
||||
|---|---|
|
||||
| `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) |
|
||||
| `serialized` | array meta values are stored serialized in flat tables, never "Array" |
|
||||
| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) |
|
||||
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
|
||||
|
||||
## Tools (`tools/`)
|
||||
|
||||
@@ -113,6 +113,29 @@ final class TMDO_Live_Fixtures {
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a throwaway comment (on a throwaway post of $post_type).
|
||||
*
|
||||
* @param string $post_type Post type of the parent post.
|
||||
* @param string $comment_type Comment type.
|
||||
*/
|
||||
public static function comment( string $post_type, string $comment_type = 'comment' ): int {
|
||||
$post_id = self::post( $post_type );
|
||||
$id = wp_insert_comment(
|
||||
array(
|
||||
'comment_post_ID' => $post_id,
|
||||
'comment_content' => 'tmdo-live',
|
||||
'comment_type' => $comment_type,
|
||||
'comment_approved' => 1,
|
||||
)
|
||||
);
|
||||
if ( ! $id ) {
|
||||
throw new TMDO_Live_Failure( 'fixture comment could not be created' );
|
||||
}
|
||||
self::$created[] = array( 'comment', (int) $id );
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native meta values for a key, read with SQL (no filters, no cache).
|
||||
*
|
||||
@@ -191,6 +214,8 @@ final class TMDO_Live_Fixtures {
|
||||
wp_delete_term( $c[1], $c[2] );
|
||||
} elseif ( 'post' === $c[0] ) {
|
||||
wp_delete_post( $c[1], true );
|
||||
} elseif ( 'comment' === $c[0] ) {
|
||||
wp_delete_comment( $c[1], true );
|
||||
}
|
||||
}
|
||||
self::$created = array();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
/**
|
||||
* Suite: in dual_write / shadow_read the flat value always follows native meta.
|
||||
*
|
||||
* Before PR-057 the Hook Bus wrote flat in the pre-filter, before WP decided
|
||||
* whether native changed at all: a non-matching prev_value, add($unique) on an
|
||||
* existing key or a key change via update_metadata_by_mid left flat and native
|
||||
* apart, and nothing healed a flat value made stale by a direct SQL write.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Entity cases: [entity type, key, fixture factory, post type for group lookup].
|
||||
*
|
||||
* @return array<string, array{0:string, 1:string, 2:callable, 3:string}>
|
||||
*/
|
||||
function tmdo_live_resync_cases(): array {
|
||||
return array(
|
||||
'user first_name' => array( 'user', 'first_name', array( 'TMDO_Live_Fixtures', 'user' ), '' ),
|
||||
'hp_listing price' => array( 'post', 'hp_price', static fn() => TMDO_Live_Fixtures::post( 'hp_listing' ), 'hp_listing' ),
|
||||
'hp term icon' => array( 'term', 'hp_icon', static fn() => TMDO_Live_Fixtures::term( 'hp_listing_category' ), '' ),
|
||||
'hp review rating' => array( 'comment', 'hp_rating', static fn() => TMDO_Live_Fixtures::comment( 'hp_listing', 'hp_review' ), '' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert flat equals the first native value (numeric-aware; missing native = NULL flat).
|
||||
*/
|
||||
function tmdo_live_assert_in_step( string $type, int $id, string $key, string $post_type, string $context ): void {
|
||||
$native = TMDO_Live_Fixtures::native( $type, $id, $key );
|
||||
$flat = TMDO_Live_Fixtures::flat( $type, $id, $key, $post_type );
|
||||
$expect = $native ? $native[0] : null;
|
||||
if ( 'NO-ROW' === $flat ) {
|
||||
$flat = null;
|
||||
}
|
||||
$same = ( null === $expect || null === $flat )
|
||||
? $expect === $flat
|
||||
: ( is_numeric( $expect ) && is_numeric( $flat ) ? (float) $expect === (float) $flat : $expect === $flat );
|
||||
tmdo_live_assert_true( $same, sprintf( '%s — native %s, flat %s', $context, var_export( $expect, true ), var_export( $flat, true ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
|
||||
}
|
||||
|
||||
foreach ( array( 'dual_write', 'shadow_read' ) as $tmdo_live_mode ) {
|
||||
foreach ( tmdo_live_resync_cases() as $tmdo_live_label => $tmdo_live_case ) {
|
||||
list( $type, $key, $make, $pt ) = $tmdo_live_case;
|
||||
|
||||
TMDO_Live::test(
|
||||
"{$tmdo_live_mode} {$tmdo_live_label}: update, same value, delete",
|
||||
static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) {
|
||||
TMDO_Live_Fixtures::field( $type, $key, $pt );
|
||||
TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) );
|
||||
$id = $make();
|
||||
|
||||
update_metadata( $type, $id, $key, '11' );
|
||||
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'new key' );
|
||||
update_metadata( $type, $id, $key, '12' );
|
||||
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'update' );
|
||||
update_metadata( $type, $id, $key, '12' );
|
||||
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'same value' );
|
||||
delete_metadata( $type, $id, $key, '12' );
|
||||
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'delete with value' );
|
||||
}
|
||||
);
|
||||
|
||||
TMDO_Live::test(
|
||||
"{$tmdo_live_mode} {$tmdo_live_label}: writes WP refuses leave flat alone",
|
||||
static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) {
|
||||
TMDO_Live_Fixtures::field( $type, $key, $pt );
|
||||
TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) );
|
||||
$id = $make();
|
||||
update_metadata( $type, $id, $key, '21' );
|
||||
|
||||
update_metadata( $type, $id, $key, '22', 'no-such-previous-value' );
|
||||
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'non-matching prev_value' );
|
||||
add_metadata( $type, $id, $key, '23', true );
|
||||
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'add unique on existing key' );
|
||||
update_metadata( $type, $id, $key, '24', '21' );
|
||||
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'matching prev_value' );
|
||||
}
|
||||
);
|
||||
|
||||
TMDO_Live::test(
|
||||
"{$tmdo_live_mode} {$tmdo_live_label}: stale flat heals on the next write, key change by meta_id",
|
||||
static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) {
|
||||
global $wpdb;
|
||||
TMDO_Live_Fixtures::field( $type, $key, $pt );
|
||||
TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) );
|
||||
$id = $make();
|
||||
update_metadata( $type, $id, $key, '31' );
|
||||
|
||||
$table = _get_meta_table( $type );
|
||||
$id_col = $type . '_id';
|
||||
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||
|
||||
// A direct SQL write (as WooCommerce does for stock) leaves flat stale.
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP meta table names.
|
||||
$wpdb->query( $wpdb->prepare( "UPDATE {$table} SET meta_value = '32' WHERE {$id_col} = %d AND meta_key = %s", $id, $key ) );
|
||||
wp_cache_delete( $id, $type . '_meta' );
|
||||
update_metadata( $type, $id, $key, '33' );
|
||||
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'next write after a direct SQL change' );
|
||||
|
||||
// Renaming the key by meta_id removes it from native: flat must follow.
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP meta table names.
|
||||
$mid = (int) $wpdb->get_var( $wpdb->prepare( "SELECT {$pk} FROM {$table} WHERE {$id_col} = %d AND meta_key = %s", $id, $key ) );
|
||||
update_metadata_by_mid( $type, $mid, '34', 'tmdo_live_renamed' );
|
||||
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'key renamed by meta_id' );
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user