$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 */ private static array $pending_deletes = array(); /** * Old keys of rows renamed through update_metadata_by_mid, keyed by meta ID. * * @var array */ 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 $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|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 ); } }