$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 ) { // 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'] ); // 鎖防遞迴 $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; } // 空 key:WP 要求所有 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 ); } }