77e338f9c3
- 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
960 lines
34 KiB
PHP
960 lines
34 KiB
PHP
<?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 );
|
||
}
|
||
}
|