Files
2meet-data-optimizer/includes/engine/class-tmdo-hook-bus.php
T
wpdev 77e338f9c3 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
2026-09-26 19:13:02 +08:00

960 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_Hook_Bus - 統一 Hook 攔截匯流排
*
* 所有 WordPress 的 meta 操作皆通過此匯流排:
* - {type}_metadata 系列 filter(add/get/update/delete)
* - 實體刪除 action
* - 原生查詢擴充 hook
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Hook_Bus {
/** 標記:防止在內部 UPSERT 時遞迴觸發 filter */
private static array $internal_ops = array();
public static function init(): void {
// 取得所有已註冊的適配器
$adapters = TMDO_Entity_Registry::get_all_adapters();
foreach ( $adapters as $type => $adapter ) {
self::register_hooks_for_type( $type, $adapter );
}
}
private static function register_hooks_for_type( string $type, TMDO_Entity_Adapter_Interface $adapter ): void {
// ── 寫入攔截 ──────────────────────────────────────────
add_filter( "update_{$type}_metadata", array( self::class, 'intercept_update' ), 10, 5 );
add_filter( "add_{$type}_metadata", array( self::class, 'intercept_add' ), 10, 5 );
// ── 讀取攔截 ──────────────────────────────────────────
add_filter( "get_{$type}_metadata", array( self::class, 'intercept_get' ), 10, 5 );
// ── 刪除攔截 ──────────────────────────────────────────
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(),
function ( $entity_id ) use ( $type, $adapter ) {
self::cleanup_entity( $type, (int) $entity_id );
},
10,
1
);
}
// ─────────────────────────────────────────────────────────
// 寫入:update_{type}_metadata filter
// ─────────────────────────────────────────────────────────
/**
* 短路 WordPress 原生 update_metadata() 流程
*
* @param null|bool $check 若回傳 null 則 WP 繼續原生流程
* @param int $object_id
* @param string $meta_key
* @param mixed $meta_value
* @param mixed $prev_value
*/
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
return $check;
}
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
if ( ! $field_def ) {
return $check; // 非管理欄位,放行
}
// ── Mode-aware dispatch ──────────────────────────────
// 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
}
// Route decision (v1.2.0):讓 UAEPG 等外掛正式訂閱 routing,不必搶 priority 5。
$route = self::decide_route( 'update', $type, (int) $object_id, $meta_key, $meta_value );
if ( $route === 'pg' ) {
// 讓其他 listener(例如 UAEPG)接手;原生 EAV 也放行。
return null;
}
if ( $route === 'skip' ) {
// 不寫 flat、不寫 EAV,但告訴 WP 已處理。
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' );
$flat_result = self::perform_upsert( $type, (int) $object_id, $meta_key, $meta_value, $field_def );
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
return $flat_result; // aeav_only
}
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
return $check;
}
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
if ( ! $field_def ) {
return $check;
}
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
return $check;
}
$route = self::decide_route( 'add', $type, (int) $object_id, $meta_key, $meta_value );
if ( $route === 'pg' ) {
return null;
}
if ( $route === 'skip' ) {
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' );
$flat_result = self::perform_upsert( $type, (int) $object_id, $meta_key, $meta_value, $field_def );
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
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;
}
$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'],
);
}
/**
* 如 filter `wpdo_capture_before_value` 回 true 才讀 flat table 的 before value,
* 否則直接回 null — 讓沒在用 audit / 其他 listener 的站台省一次 DB read。
*
* filter 參數:(bool $default_true, string $type, string $meta_key, string $op)
* $op ∈ { 'add', 'update', 'delete' }
*
* 使用範例(關閉 audit 的站台):
* add_filter( 'wpdo_capture_before_value', '__return_false' );
*
* @since 1.3.2
*/
private static function maybe_read_before_value(
string $type,
int $entity_id,
string $meta_key,
array $field_def,
string $op
) {
// Default false since v3.1.5 (A): reading the previous flat value costs an
// extra DB read on every managed write. Sites that consume value_before —
// e.g. the audit log — must opt in:
// add_filter( 'wpdo_capture_before_value', '__return_true' );
$capture = apply_filters(
'wpdo_capture_before_value',
false,
$type,
$meta_key,
$op
);
if ( ! $capture ) {
return null;
}
return self::read_flat_value( $type, $entity_id, $meta_key, $field_def );
}
/**
* 讀取 flat table 中當前值(before value,供 audit / after_write listener 使用)。
*
* 此方法**不經 cache 加熱**,直接查 DB,以避免快取污染與遞迴。表不存在回 null。
*
* @since 1.3.1
*/
private static function read_flat_value(
string $type,
int $entity_id,
string $meta_key,
array $field_def
) {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$group = $field_def['group'] ?? '';
if ( $group === '' ) {
return null;
}
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return null;
}
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
$id_col = $adapter->get_entity_id_column();
$raw = $wpdb->get_var(
$wpdb->prepare(
"SELECT `{$col}` FROM `{$table}` WHERE `{$id_col}` = %d LIMIT 1",
$entity_id
)
);
return $raw === null ? null : TMDO_Type_Caster::from_db( $raw, $field_def );
}
/**
* 讓外部 listener(例如 UAEPG)透過 `wpdo_route_decision` filter 指定路由。
*
* 回傳值:
* 'flat' (預設) — UAE 寫入 MySQL flat table
* 'pg' — 放行,由其他 listener 接手;UAE 不寫 flat,原生 EAV 依 mode 決定
* 'skip' — 都不寫(用於軟刪除之類特殊情境),但告訴 WP 已處理
*
* 其他非預期值會被 fallback 到 'flat' 以維持安全預設。
*
* @since 1.2.0
*/
private static function decide_route(
string $op,
string $type,
int $object_id,
string $meta_key,
$meta_value
): string {
$route = apply_filters(
'wpdo_route_decision',
'flat',
$type,
$object_id,
$meta_key,
$meta_value,
$op
);
if ( in_array( $route, array( 'flat', 'pg', 'skip' ), true ) ) {
return $route;
}
TMDO_Logger::warning(
'wpdo_route_decision_invalid_return',
array(
'returned' => is_scalar( $route ) ? (string) $route : gettype( $route ),
'op' => $op,
'type' => $type,
'key' => $meta_key,
)
);
return 'flat';
}
/**
* 執行 Upsert 操作
*/
private static function perform_upsert(
string $type,
int $entity_id,
string $meta_key,
$meta_value,
array $field_def
) {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$group = $field_def['group'];
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
$id_col = $adapter->get_entity_id_column();
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
// 表不存在則讓 WP 走原生流程(降級處理)
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return null;
}
// 型別轉換
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
// 鎖防遞迴(含 entity type:user 5 與 post 5 不可互鎖)
$lock_key = $type . ':' . $entity_id . ':' . $meta_key;
self::$internal_ops[ $lock_key ] = true;
try {
// 單一 upsert,避免 SELECT→UPDATE/INSERT 的 read-modify-write race。
// post:新列一併帶入 post_status / post_date 副本(只在 insert 時寫入)。
$extra = 'post' === $type ? TMDO_Post_Status_Sync::insert_columns( $entity_id, $group ) : array();
$result = TMDO_DB::upsert(
$table,
array_merge(
array(
$id_col => $entity_id,
$col => $db_value,
),
$extra
),
array( $col ),
$id_col,
array_merge( array( '%d', $format ), array_fill( 0, count( $extra ), '%s' ) )
);
// 清除快取
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
self::touch_posts_last_changed( $type );
// 短路回傳 true(WP 認為寫入成功)
return $result !== false;
} finally {
unset( self::$internal_ops[ $lock_key ] );
}
}
// ─────────────────────────────────────────────────────────
// 讀取:get_{type}_metadata filter
// ─────────────────────────────────────────────────────────
/**
* 攔截 get_metadata() 呼叫
*
* @param null|mixed $check 若回傳 null 則 WP 繼續原生流程
* @param int $object_id
* @param string $meta_key 空字串表示取所有 meta
* @param bool $single
* @param string $meta_type 5.5+ 額外參數
*/
public static function intercept_get( $check, $object_id, $meta_key, $single, $meta_type = '' ) {
$type = $meta_type ?: self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
// 空 key:WP 要求所有 meta,UAE 不攔截(維持相容性)
if ( $meta_key === '' ) {
return $check;
}
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
if ( ! $field_def ) {
return $check;
}
// ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
// shadow_read : 讀取仍走 EAV,抽樣與 flat 比對記錄 diff
// aeav_only : 讀取走 UAE flat,不讀 EAV
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
return $check; // disabled / dual_write
}
// shadow_read serves native: update_metadata() reads the old value
// through this filter, and serving the just-upserted flat value made
// WP see old === new and skip the native write (dev36, 2026-09-26).
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
self::maybe_shadow_compare( $type, (int) $object_id, $meta_key, $field_def );
return $check;
}
$group = $field_def['group'];
$row = self::get_or_load_row( $type, (int) $object_id, $group );
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
$has_value = is_array( $row ) && array_key_exists( $col, $row );
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
// 值為空 → 回 WP 原生慣例
if ( empty( $row ) || $value === null || $value === '' ) {
return $single ? '' : array();
}
// WP 的慣例:get_metadata() 即使 $single=true 也回傳陣列包裝
return $single ? array( $value ) : array( $value );
}
/**
* shadow_read: compare the flat value with native meta for a sample of reads.
*
* Each compare costs a flat row read and a native SQL read, so only a share
* of reads is checked (filter `tmdo_shadow_compare_sample_rate`, 0..1,
* default 0.1). The logger rate-limits repeated diffs itself.
*
* @param string $type Entity type.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param array<string, mixed> $field_def Field definition.
*/
private static function maybe_shadow_compare( string $type, int $object_id, string $meta_key, array $field_def ): void {
$rate = (float) apply_filters( 'tmdo_shadow_compare_sample_rate', 0.1, $type, $meta_key );
if ( $rate <= 0 || ( $rate < 1 && wp_rand( 1, 1000 ) > $rate * 1000 ) ) {
return;
}
$row = self::get_or_load_row( $type, $object_id, $field_def['group'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
$value = array_key_exists( $col, $row ) ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
try {
TMDO_Shadow_Diff_Logger::compare_and_log( $type, $object_id, $meta_key, $value, $field_def );
} catch ( \Throwable $e ) {
// 比對失敗不該影響讀取
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
}
}
/**
* 取得(或載入)完整列資料,並快取
*/
private static function get_or_load_row( string $type, int $entity_id, string $group ): array {
// L1 快取
$cached = TMDO_Cache_Orchestrator::get_row( $type, $entity_id, $group );
if ( is_array( $cached ) ) {
return $cached;
}
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return array();
}
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
$id_col = $adapter->get_entity_id_column();
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return array();
}
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM `{$table}` WHERE `{$id_col}` = %d LIMIT 1",
$entity_id
),
ARRAY_A
);
$row = $row ?: array();
TMDO_Cache_Orchestrator::set_row( $type, $entity_id, $group, $row );
return $row;
}
// ─────────────────────────────────────────────────────────
// 刪除:delete_{type}_metadata filter
// ─────────────────────────────────────────────────────────
public static function intercept_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
if ( ! $field_def ) {
return $check;
}
// Mode-aware:disabled 完全不攔截
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
return $check;
}
$route = self::decide_route( 'delete', $type, (int) $object_id, $meta_key, $meta_value );
if ( $route === 'pg' ) {
return null;
}
if ( $route === 'skip' ) {
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' );
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
$group = $field_def['group'];
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
$id_col = $adapter->get_entity_id_column();
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return $check;
}
// UAE 的邏輯:刪除 meta = 設該欄位為 NULL
// 因為一個 entity 只對應一列,完整刪除列會丟失其他欄位
$default = $field_def['default'] ?? null;
if ( $delete_all ) {
/**
* Opt-in gate for mass column clear (delete_all=true).
*
* Defaults to false — a single mistargetted call NULLs the column for
* every entity, and the previous row-count heuristic let small sites
* (< 500 rows) through unguarded. Enable only in controlled contexts:
* add_filter( 'wpdo_allow_mass_column_clear', '__return_true' );
*
* @param bool $allow Whether to allow the mass clear. Default false.
* @param string $table Flat table name.
* @param string $col Column being cleared.
* @param string $meta_key Original meta key.
*/
$allowed = (bool) apply_filters( 'wpdo_allow_mass_column_clear', false, $table, $col, $meta_key );
/** This filter is documented above (tmdo_* is the forward-looking name). */
$allowed = (bool) apply_filters( 'tmdo_allow_mass_column_clear', $allowed, $table, $col, $meta_key );
if ( ! $allowed ) {
TMDO_Logger::warning(
'intercept_delete_mass_blocked',
array(
'table' => $table,
'col' => $col,
'reason' => 'enable via wpdo_allow_mass_column_clear filter',
)
);
return false;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
TMDO_Logger::warning(
'intercept_delete_all',
array(
'table' => $table,
'col' => $col,
'rows' => $row_count,
)
);
// 刪除所有 entity 的該欄位
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE `{$table}` SET `{$col}` = %s",
$default
)
);
} else {
$result = $wpdb->update(
$table,
array( $col => $default ),
array( $id_col => $object_id ),
array( TMDO_Type_Caster::get_wpdb_format( $field_def['type'] ) ),
array( '%d' )
);
TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group );
}
self::touch_posts_last_changed( $type );
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
return $result !== false; // aeav_only
}
// ─────────────────────────────────────────────────────────
// 實體刪除清理
// ─────────────────────────────────────────────────────────
public static function cleanup_entity( string $type, int $entity_id ): void {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return;
}
$id_col = $adapter->get_entity_id_column();
$groups = TMDO_Entity_Registry::get_groups_for_type( $type );
foreach ( $groups as $group ) {
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
$wpdb->delete( $table, array( $id_col => $entity_id ), array( '%d' ) );
}
}
TMDO_Cache_Orchestrator::flush_entity( $type, $entity_id );
}
// ─────────────────────────────────────────────────────────
// 工具方法
// ─────────────────────────────────────────────────────────
/**
* Flat post writes skip update_metadata()'s own hooks in aeav_only, so
* move posts' last_changed here: caches keyed on it (WP query caches,
* routed found-rows counts) must not outlive the write.
*
* @param string $type Entity type.
* @return void
*/
private static function touch_posts_last_changed( string $type ): void {
if ( 'post' === $type ) {
wp_cache_set_posts_last_changed();
}
}
/**
* 從當前 filter 名稱推斷實體類型
* 例:update_user_metadata → user
*/
private static function resolve_type_from_current_filter(): ?string {
$current = current_filter();
if ( preg_match( '/^(?:add|get|update|delete)_(post|user|term|comment)_metadata$/', $current, $matches ) ) {
return $matches[1];
}
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())。
*
* 先做不帶 post_type 的查詢:大多數 key 不受管理,可省下 get_post_type()。
* @return array<string, mixed>|null
*/
private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( null === $field_def || 'post' !== $type ) {
return $field_def;
}
return TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
}
/**
* 直接讀取(繞過 WP filter 系統)
* 供 wpdo_get_meta() 便利函式使用,性能更好
*
* @param string $type
* @param int $entity_id
* @param string $key 若為空字串則回傳整列
* @return mixed
*/
public static function direct_read( string $type, int $entity_id, string $key = '' ) {
if ( $key === '' ) {
// 回傳所有群組的所有欄位
$result = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
$row = self::get_or_load_row( $type, $entity_id, $group );
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
$result[ $field['key'] ] = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
}
}
return $result;
}
$field_def = self::field_for( $type, $entity_id, $key );
if ( ! $field_def ) {
return null;
}
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
return TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field_def );
}
}