Files
2meet-data-optimizer/includes/engine/class-tmdo-hook-bus.php
T
wpdev caf0351294 fix(hook-bus): write_to_flat 改用 TMDO_DB::upsert(B4)
原本 SELECT id → UPDATE/INSERT 三步,並發寫入同一 entity 會產生
duplicate row 或互相覆蓋。改為單一 upsert(A v3.1.7 同款)。

unit 379 / integration 398 GREEN

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 05:35:20 +08:00

653 lines
21 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 系列 filteradd/get/update/delete
* - 實體刪除 action
* - 原生查詢擴充 hook
*
* @package WP_Data_Optimizer
*/
// 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 );
// ── 實體刪除時自動清理 ───────────────────────────────
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 ) {
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
return $check;
}
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( ! $field_def ) {
return $check; // 非管理欄位,放行
}
// ── 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)
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;
}
// 截取 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 );
// 若 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 ] ) ) {
return $check;
}
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $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;
}
// 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 );
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
return $flat_result;
}
/**
* 如 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
) {
$capture = apply_filters(
'wpdo_capture_before_value',
true,
$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'] );
// 鎖防遞迴
$lock_key = $entity_id . ':' . $meta_key;
self::$internal_ops[ $lock_key ] = true;
try {
// 單一 upsert,避免 SELECT→UPDATE/INSERT 的 read-modify-write race。
$result = TMDO_DB::upsert(
$table,
array(
$id_col => $entity_id,
$col => $db_value,
),
array( $col ),
$id_col,
array( '%d', $format )
);
// 清除快取
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
// 短路回傳 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;
}
// 空 keyWP 要求所有 meta,UAE 不攔截(維持相容性)
if ( $meta_key === '' ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( ! $field_def ) {
return $check;
}
// ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff
// aeav_only : 讀取走 UAE flat,不讀 EAV
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
return $check; // disabled / dual_write
}
$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;
// Shadow-read:與 EAV 比對,記錄差異
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
try {
TMDO_Shadow_Diff_Logger::compare_and_log(
$type,
(int) $object_id,
$meta_key,
$value,
$field_def
);
} catch ( \Throwable $e ) {
// 比對失敗不該影響讀取
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
}
}
// 值為空 → 回 WP 原生慣例
if ( empty( $row ) || $value === null || $value === '' ) {
return $single ? '' : array();
}
// WP 的慣例:get_metadata() 即使 $single=true 也回傳陣列包裝
return $single ? array( $value ) : array( $value );
}
/**
* 取得(或載入)完整列資料,並快取
*/
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 = TMDO_Entity_Registry::get_field( $type, $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;
}
// 截取 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 );
}
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;
}
// ─────────────────────────────────────────────────────────
// 實體刪除清理
// ─────────────────────────────────────────────────────────
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 );
}
// ─────────────────────────────────────────────────────────
// 工具方法
// ─────────────────────────────────────────────────────────
/**
* 從當前 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;
}
/**
* 直接讀取(繞過 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 = TMDO_Entity_Registry::get_field( $type, $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 );
}
}