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())。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user