Files
2meet-data-optimizer/includes/engine/class-tmdo-hook-bus.php
T
wpdev b84252d787 fix(2meet-data-optimizer): serve native meta in shadow_read and compare flat values
- In shadow_read, TMDO_Hook_Bus::intercept_get() returned the flat value.
  update_metadata() reads the old value through that filter after the Hook
  Bus has upserted the new one, so WP saw old === new and never wrote native
  meta: every update in shadow_read was lost on demotion (dev36 2026-09-26).
  shadow_read now returns $check (native) and compares flat on a sample of
  reads (filter tmdo_shadow_compare_sample_rate, default 0.1). aeav_only is
  unchanged; the post query router still routes in shadow_read.
- Live suite shadow_write: xfail markers removed, HivePress listing case
  added (new int key hp_rating_count); fixtures gain post() and a
  post_type-scoped field()/flat().
- Verify: live shadow_write + serialized 8/8 PASS (old code: 4 FAIL);
  unit 685 OK, integration 484 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-056
2026-09-26 18:56:45 +08:00

717 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_Hook_Bus - 統一 Hook 攔截匯流排
*
* 所有 WordPress 的 meta 操作皆通過此匯流排:
* - {type}_metadata 系列 filter(add/get/update/delete)
* - 實體刪除 action
* - 原生查詢擴充 hook
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Hook_Bus {
/** 標記:防止在內部 UPSERT 時遞迴觸發 filter */
private static array $internal_ops = array();
public static function init(): void {
// 取得所有已註冊的適配器
$adapters = TMDO_Entity_Registry::get_all_adapters();
foreach ( $adapters as $type => $adapter ) {
self::register_hooks_for_type( $type, $adapter );
}
}
private static function register_hooks_for_type( string $type, TMDO_Entity_Adapter_Interface $adapter ): void {
// ── 寫入攔截 ──────────────────────────────────────────
add_filter( "update_{$type}_metadata", array( self::class, 'intercept_update' ), 10, 5 );
add_filter( "add_{$type}_metadata", array( self::class, 'intercept_add' ), 10, 5 );
// ── 讀取攔截 ──────────────────────────────────────────
add_filter( "get_{$type}_metadata", array( self::class, 'intercept_get' ), 10, 5 );
// ── 刪除攔截 ──────────────────────────────────────────
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
// ── 實體刪除時自動清理 ───────────────────────────────
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 = self::field_for( $type, (int) $object_id, $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 = 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;
}
// 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。
// 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;
}
// 截取 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 );
// 若還要寫 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 );
}
// ─────────────────────────────────────────────────────────
// 工具方法
// ─────────────────────────────────────────────────────────
/**
* 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;
}
/**
* 欄位定義查詢;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 );
}
}