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:
@@ -44,6 +44,15 @@ final class TMDO_Hook_Bus {
|
||||
// ── 刪除攔截 ──────────────────────────────────────────
|
||||
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
|
||||
|
||||
// ── dual_write / shadow_read:原生寫入完成後,flat 從原生同步 ──
|
||||
// Native is the source of truth in these modes; flat follows what
|
||||
// native actually became (WP may skip or refuse a write).
|
||||
add_action( "added_{$type}_meta", array( self::class, 'on_meta_added' ), 1, 4 );
|
||||
add_action( "updated_{$type}_meta", array( self::class, 'on_meta_updated' ), 1, 4 );
|
||||
add_action( "delete_{$type}_meta", array( self::class, 'before_meta_deleted' ), 1, 4 );
|
||||
add_action( "deleted_{$type}_meta", array( self::class, 'on_meta_deleted' ), 1, 4 );
|
||||
add_filter( "update_{$type}_metadata_by_mid", array( self::class, 'before_update_by_mid' ), 1, 4 );
|
||||
|
||||
// ── 實體刪除時自動清理 ───────────────────────────────
|
||||
add_action(
|
||||
$adapter->get_delete_hook(),
|
||||
@@ -70,13 +79,13 @@ final class TMDO_Hook_Bus {
|
||||
*/
|
||||
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
|
||||
|
||||
// 已在短路中 → 避免遞迴
|
||||
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
|
||||
$type = self::resolve_type_from_current_filter();
|
||||
if ( ! $type ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$type = self::resolve_type_from_current_filter();
|
||||
if ( ! $type ) {
|
||||
// 已在短路中 → 避免遞迴
|
||||
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
@@ -86,10 +95,10 @@ final class TMDO_Hook_Bus {
|
||||
}
|
||||
|
||||
// ── Mode-aware dispatch ──────────────────────────────
|
||||
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
|
||||
// dual_write: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
|
||||
// shadow_read: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
|
||||
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
|
||||
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
|
||||
// dual_write : 放行 WP 寫 EAV;updated_/added_ action 再從原生同步 flat
|
||||
// shadow_read: 同 dual_write
|
||||
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
|
||||
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
|
||||
return $check; // disabled
|
||||
}
|
||||
@@ -105,6 +114,13 @@ final class TMDO_Hook_Bus {
|
||||
return true;
|
||||
}
|
||||
|
||||
// dual_write / shadow_read: native first. Writing flat here, before WP
|
||||
// decides whether native changes at all (same value, non-matching
|
||||
// prev_value), let flat drift; sync_from_native() runs after the write.
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
|
||||
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。
|
||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' );
|
||||
@@ -113,23 +129,18 @@ final class TMDO_Hook_Bus {
|
||||
|
||||
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
|
||||
|
||||
// 若 mode 也要寫 EAV → return null 讓 WP 繼續
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null; // dual_write / shadow_read
|
||||
}
|
||||
|
||||
return $flat_result; // aeav_only
|
||||
}
|
||||
|
||||
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
|
||||
|
||||
// 已在短路中 → 避免遞迴
|
||||
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
|
||||
$type = self::resolve_type_from_current_filter();
|
||||
if ( ! $type ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$type = self::resolve_type_from_current_filter();
|
||||
if ( ! $type ) {
|
||||
// 已在短路中 → 避免遞迴
|
||||
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
@@ -150,6 +161,12 @@ final class TMDO_Hook_Bus {
|
||||
return true;
|
||||
}
|
||||
|
||||
// dual_write / shadow_read: native first (add($unique) may be refused);
|
||||
// sync_from_native() runs on added_{type}_meta.
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
|
||||
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。
|
||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' );
|
||||
@@ -158,11 +175,225 @@ final class TMDO_Hook_Bus {
|
||||
|
||||
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
|
||||
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
return $flat_result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// dual_write / shadow_read:原生寫入後同步 flat
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Entity IDs whose native rows a pending delete removes, keyed "type:key".
|
||||
*
|
||||
* @var array<string, int[]>
|
||||
*/
|
||||
private static array $pending_deletes = array();
|
||||
|
||||
/**
|
||||
* Old keys of rows renamed through update_metadata_by_mid, keyed by meta ID.
|
||||
*
|
||||
* @var array<string, array{type:string, id:int, key:string}>
|
||||
*/
|
||||
private static array $pending_renames = array();
|
||||
|
||||
/**
|
||||
* Copy the entity's current native value for a key into its flat column
|
||||
* (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.
|
||||
*
|
||||
* @param string $type Entity type.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Value the caller wrote (for listeners).
|
||||
* @param string $op 'add' | 'update' | 'delete'.
|
||||
* @param bool $delete_all Whether the delete was a delete_all.
|
||||
* @return void
|
||||
*/
|
||||
public static function sync_from_native( string $type, int $object_id, string $meta_key, $meta_value, string $op, bool $delete_all = false ): void {
|
||||
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return;
|
||||
}
|
||||
$field_def = self::field_for( $type, $object_id, $meta_key );
|
||||
if ( ! $field_def || 'flat' !== self::decide_route( $op, $type, $object_id, $meta_key, $meta_value ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
return $flat_result;
|
||||
$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 );
|
||||
|
||||
if ( 'delete' === $op ) {
|
||||
do_action( 'wpdo_after_delete', $type, $object_id, $meta_key, $meta_value, $result, $delete_all, $before );
|
||||
} else {
|
||||
do_action( 'wpdo_after_write', $type, $object_id, $meta_key, $meta_value, $result, $op, $before );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `added_{type}_meta` action.
|
||||
*
|
||||
* @param int $meta_id Meta ID.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
*/
|
||||
public static function on_meta_added( $meta_id, $object_id, $meta_key, $meta_value ): void {
|
||||
$type = self::resolve_type_from_meta_hook();
|
||||
if ( $type ) {
|
||||
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'add' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `updated_{type}_meta` action; also re-syncs the old key of a row renamed
|
||||
* through update_metadata_by_mid.
|
||||
*
|
||||
* @param int $meta_id Meta ID.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
*/
|
||||
public static function on_meta_updated( $meta_id, $object_id, $meta_key, $meta_value ): void {
|
||||
$type = self::resolve_type_from_meta_hook();
|
||||
if ( ! $type ) {
|
||||
return;
|
||||
}
|
||||
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'update' );
|
||||
|
||||
$rename_key = $type . ':' . (int) $meta_id;
|
||||
if ( isset( self::$pending_renames[ $rename_key ] ) ) {
|
||||
$old = self::$pending_renames[ $rename_key ];
|
||||
unset( self::$pending_renames[ $rename_key ] );
|
||||
self::sync_from_native( $type, $old['id'], $old['key'], null, 'delete' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_{type}_metadata_by_mid` filter: remember the old key when a row
|
||||
* is renamed, so its flat column can follow. Never changes $check.
|
||||
*
|
||||
* @param mixed $check Short-circuit value.
|
||||
* @param int $meta_id Meta ID.
|
||||
* @param mixed $meta_value New value.
|
||||
* @param string|false $meta_key New key, or false to keep it.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function before_update_by_mid( $check, $meta_id, $meta_value, $meta_key ) {
|
||||
$type = self::resolve_type_from_meta_hook();
|
||||
if ( ! $type || false === $meta_key ) {
|
||||
return $check;
|
||||
}
|
||||
$row = self::native_row_by_mid( $type, (int) $meta_id );
|
||||
if ( $row && $row['key'] !== $meta_key && TMDO_Entity_Registry::get_field( $type, $row['key'] ) ) {
|
||||
self::$pending_renames[ $type . ':' . (int) $meta_id ] = array(
|
||||
'type' => $type,
|
||||
'id' => $row['id'],
|
||||
'key' => $row['key'],
|
||||
);
|
||||
}
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* `delete_{type}_meta` action (rows still exist): record which entities a
|
||||
* delete touches — a delete_all passes object ID 0.
|
||||
*
|
||||
* @param int[] $meta_ids Meta IDs to delete.
|
||||
* @param int $object_id Entity ID (0 for delete_all).
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
*/
|
||||
public static function before_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
|
||||
$type = self::resolve_type_from_meta_hook();
|
||||
if ( ! $type || ! TMDO_Entity_Registry::get_field( $type, (string) $meta_key ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return;
|
||||
}
|
||||
$ids = array();
|
||||
foreach ( (array) $meta_ids as $mid ) {
|
||||
$row = self::native_row_by_mid( $type, (int) $mid );
|
||||
if ( $row ) {
|
||||
$ids[ $row['id'] ] = $row['id'];
|
||||
}
|
||||
}
|
||||
self::$pending_deletes[ $type . ':' . $meta_key ] = array_values( $ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* `deleted_{type}_meta` action: re-sync every entity the delete touched.
|
||||
*
|
||||
* @param int[] $meta_ids Deleted meta IDs.
|
||||
* @param int $object_id Entity ID (0 for delete_all).
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
*/
|
||||
public static function on_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
|
||||
$type = self::resolve_type_from_meta_hook();
|
||||
if ( ! $type ) {
|
||||
return;
|
||||
}
|
||||
$pending_key = $type . ':' . $meta_key;
|
||||
$ids = self::$pending_deletes[ $pending_key ] ?? array( (int) $object_id );
|
||||
unset( self::$pending_deletes[ $pending_key ] );
|
||||
|
||||
foreach ( $ids as $id ) {
|
||||
if ( $id > 0 ) {
|
||||
self::sync_from_native( $type, (int) $id, (string) $meta_key, $meta_value, 'delete', 0 === (int) $object_id );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First native value of a key (the one get_*_meta( $id, $key, true ) returns),
|
||||
* read with SQL so no meta filter runs. Null when the entity has no row.
|
||||
*
|
||||
* @param string $type Entity type.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function native_first_value( string $type, int $object_id, string $meta_key ): ?string {
|
||||
global $wpdb;
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||
if ( ! $adapter ) {
|
||||
return null;
|
||||
}
|
||||
$table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||
// get_results(): get_var() turns an empty string into null.
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$pk}` LIMIT 1", $object_id, $meta_key ),
|
||||
ARRAY_A
|
||||
);
|
||||
return $rows ? (string) $rows[0]['meta_value'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity ID and key of a native meta row.
|
||||
*
|
||||
* @param string $type Entity type.
|
||||
* @param int $meta_id Meta ID.
|
||||
* @return array{id:int, key:string}|null
|
||||
*/
|
||||
private static function native_row_by_mid( string $type, int $meta_id ): ?array {
|
||||
global $wpdb;
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||
if ( ! $adapter ) {
|
||||
return null;
|
||||
}
|
||||
$table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||
$row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key AS mkey FROM `{$table}` WHERE `{$pk}` = %d", $meta_id ), ARRAY_A );
|
||||
if ( ! $row ) {
|
||||
return null;
|
||||
}
|
||||
return array(
|
||||
'id' => (int) $row['id'],
|
||||
'key' => (string) $row['mkey'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,8 +553,8 @@ final class TMDO_Hook_Bus {
|
||||
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
|
||||
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
|
||||
|
||||
// 鎖防遞迴
|
||||
$lock_key = $entity_id . ':' . $meta_key;
|
||||
// 鎖防遞迴(含 entity type:user 5 與 post 5 不可互鎖)
|
||||
$lock_key = $type . ':' . $entity_id . ':' . $meta_key;
|
||||
self::$internal_ops[ $lock_key ] = true;
|
||||
|
||||
try {
|
||||
@@ -517,6 +748,12 @@ final class TMDO_Hook_Bus {
|
||||
return true;
|
||||
}
|
||||
|
||||
// dual_write / shadow_read: WP deletes native (also delete_all);
|
||||
// deleted_{type}_meta re-syncs every touched entity from native.
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 截取 before value(v1.3.1)
|
||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' );
|
||||
|
||||
@@ -601,12 +838,7 @@ final class TMDO_Hook_Bus {
|
||||
|
||||
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
|
||||
|
||||
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result !== false;
|
||||
return $result !== false; // aeav_only
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -666,6 +898,17 @@ final class TMDO_Hook_Bus {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity type from the meta action / by-mid filter being run
|
||||
* (added_user_meta, deleted_post_meta, update_term_metadata_by_mid, …).
|
||||
*/
|
||||
private static function resolve_type_from_meta_hook(): ?string {
|
||||
if ( preg_match( '/^(?:(?:added|updated|delete|deleted)_(post|user|term|comment)_meta|update_(post|user|term|comment)_metadata_by_mid)$/', current_filter(), $matches ) ) {
|
||||
return '' !== $matches[1] ? $matches[1] : $matches[2];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。
|
||||
*
|
||||
|
||||
@@ -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