chore: initial snapshot of 2meet-data-optimizer v0.1.0

Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
<?php
/**
* TMDO_Audit_Logger — 合規稽核用獨立 audit log 表。
*
* 訂閱 v1.2.0 起新增的 `wpdo_after_write` / `wpdo_after_delete` action,
* 把每次寫入/刪除的 who/when/what/before/after 寫進 `wp_wpdo_uni_audit`,
* 與 shadow_diffs 分離以利長期保留與合規查詢(GDPR / SOX 等)。
*
* 設計決定:
* - 同步寫入(不走 Action Scheduler)以確保 audit 先於下游動作。
* 若效能成為瓶頸,未來可改 fire-and-forget(wp_schedule_single_event)。
* - `value_before` 透過讀取 flat table 取得,若表不存在則留 null。
* - `source` 欄位以 const 列出可能的呼叫來源(rest / cli / fe-editor / internal);
* 由呼叫端透過 filter `wpdo_audit_source` 覆寫。
*
* @package WP_Data_Optimizer
* @since 1.3.0
*/
// 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_Audit_Logger {
public const TABLE_SUFFIX = 'audit';
/** 硬性 row 上限,超過時 maybe_prune 會刪除最舊的資料。 */
public const MAX_ROWS = 500_000;
/** 預設保留天數(可由 `wpdo_audit_retention_days` option 覆寫)。 */
public const DEFAULT_RETENTION_DAYS = 365;
public const OPT_RETENTION_DAYS = 'wpdo_audit_retention_days';
public const SOURCE_REST = 'rest';
public const SOURCE_CLI = 'cli';
public const SOURCE_FE_EDITOR = 'fe-editor';
public const SOURCE_INTERNAL = 'internal';
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . TMDO_TABLE_PREFIX . self::TABLE_SUFFIX;
}
public static function init(): void {
add_action( 'wpdo_after_write', array( self::class, 'on_write' ), 10, 7 );
add_action( 'wpdo_after_delete', array( self::class, 'on_delete' ), 10, 7 );
}
/**
* 寫入後訂閱 — 記錄 value_before / value_after 至 audit log。
*
* @param string $entity_type post|user|term|comment
* @param int $entity_id
* @param string $meta_key
* @param mixed $meta_value 寫入後的新值
* @param mixed $result perform_upsert 回傳值
* @param string $op 'add' | 'update'
* @param mixed $before_value 寫入前的舊值(v1.3.1+ 由 hook_bus 傳入)
*/
public static function on_write(
string $entity_type,
int $entity_id,
string $meta_key,
$meta_value,
$result,
string $op,
$before_value = null
): void {
if ( $result === false ) {
return; // 寫入失敗不記
}
$field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key );
if ( ! $field_def ) {
return; // 非 UAE 欄位
}
self::write_row(
array(
'entity_type' => $entity_type,
'entity_id' => $entity_id,
'group_name' => (string) ( $field_def['group'] ?? '' ),
'meta_key' => $meta_key,
'action' => 'write',
'op' => $op, // 'add' | 'update'
'value_before' => self::stringify( $before_value ),
'value_after' => self::stringify( $meta_value ),
)
);
}
/**
* 刪除後訂閱。
*
* @param string $entity_type
* @param int $entity_id
* @param string $meta_key
* @param mixed $meta_value WP 傳進 delete_metadata 的值(語義:匹配這個值才刪;不等於實際刪除前的 flat 值)
* @param mixed $result
* @param bool $delete_all
* @param mixed $before_value 刪除前 flat table 實際的值(v1.3.1+)
*/
public static function on_delete(
string $entity_type,
int $entity_id,
string $meta_key,
$meta_value,
$result,
bool $delete_all,
$before_value = null
): void {
if ( $result === false ) {
return;
}
$field_def = TMDO_Entity_Registry::get_field( $entity_type, $meta_key );
if ( ! $field_def ) {
return;
}
self::write_row(
array(
'entity_type' => $entity_type,
'entity_id' => $entity_id,
'group_name' => (string) ( $field_def['group'] ?? '' ),
'meta_key' => $meta_key,
'action' => 'delete',
'op' => $delete_all ? 'delete_all' : 'delete',
'value_before' => self::stringify( $before_value ),
'value_after' => null,
)
);
}
private static function write_row( array $row ): void {
global $wpdb;
$source = apply_filters(
'wpdo_audit_source',
defined( 'WP_CLI' ) && WP_CLI
? self::SOURCE_CLI
: ( defined( 'REST_REQUEST' ) && REST_REQUEST ? self::SOURCE_REST : self::SOURCE_INTERNAL )
);
// wpdb::insert 以欄位順序對應 format — 我們用 null 讓 wpdb 自己根據值型別推斷,
// 避免 format array 長度與 row key 數不對稱(v1.5.1 加 op 後更容易出錯)。
$wpdb->insert(
self::table_name(),
array_merge(
$row,
array(
'ts' => current_time( 'mysql', true ),
'user_id' => get_current_user_id(),
'source' => (string) $source,
'trace_id' => TMDO_Logger::trace_id(),
)
)
);
// 1% 機率觸發 prune — 類似 shadow_diff_logger::maybe_trim,
// 避免每次寫都查 count。
if ( wp_rand( 1, 100 ) === 1 ) {
self::maybe_prune();
}
}
/**
* 依 MAX_ROWS 與 retention_days 裁剪 audit 表。
*
* 先按時間刪除過期資料,再看是否超過 MAX_ROWS,超過則刪除最舊。
* 回傳總共刪除的 row 數(供 CLI 呈現)。
*
* @since 1.3.2
*/
public static function maybe_prune(): int {
global $wpdb;
$table = self::table_name();
$total = 0;
$retention_days = (int) get_option( self::OPT_RETENTION_DAYS, self::DEFAULT_RETENTION_DAYS );
if ( $retention_days > 0 ) {
$cutoff = gmdate( 'Y-m-d H:i:s', time() - $retention_days * DAY_IN_SECONDS );
$deleted = (int) $wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` WHERE ts < %s",
$cutoff
)
);
$total += $deleted;
if ( $deleted > 0 ) {
TMDO_Logger::info(
'audit_prune_expired',
array(
'deleted' => $deleted,
'cutoff' => $cutoff,
'days' => $retention_days,
)
);
}
}
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
if ( $count > self::MAX_ROWS ) {
$to_delete = $count - self::MAX_ROWS;
$deleted = (int) $wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` ORDER BY id ASC LIMIT %d",
$to_delete
)
);
$total += $deleted;
TMDO_Logger::warning(
'audit_prune_overflow',
array(
'deleted' => $deleted,
'count_before' => $count,
'max_rows' => self::MAX_ROWS,
)
);
}
return $total;
}
private static function stringify( $value ): ?string {
if ( $value === null ) {
return null;
}
if ( is_scalar( $value ) ) {
return (string) $value;
}
return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
}
// ─────────────────────────────────────────────────────────
// Schema
// ─────────────────────────────────────────────────────────
public static function install_table(): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
// `action` 在 MySQL 部分版本為 reserved keyword — 用 backtick 保險。
// v1.5.1 新增 `op` 欄位:細分 WordPress 內部觸發路徑(add / update / delete),
// 用來區分「add_metadata 與 update_metadata 被同時觸發」的雙 row 情境。
$sql = "CREATE TABLE {$table} (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`ts` DATETIME NOT NULL,
`user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`entity_type` VARCHAR(20) NOT NULL,
`entity_id` BIGINT UNSIGNED NOT NULL,
`group_name` VARCHAR(64) NOT NULL,
`meta_key` VARCHAR(255) NOT NULL,
`action` VARCHAR(10) NOT NULL,
`op` VARCHAR(10) NOT NULL DEFAULT 'update',
`value_before` LONGTEXT NULL,
`value_after` LONGTEXT NULL,
`source` VARCHAR(20) NULL,
`trace_id` CHAR(36) NULL,
PRIMARY KEY (`id`),
KEY `idx_entity` (`entity_type`, `entity_id`),
KEY `idx_ts` (`ts`),
KEY `idx_user` (`user_id`, `ts`),
KEY `idx_op` (`op`, `ts`)
) {$charset};";
dbDelta( $sql );
}
public static function drop_table(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() );
}
// ─────────────────────────────────────────────────────────
// Read API(供 admin / CLI 查詢)
// ─────────────────────────────────────────────────────────
/**
* 取得最近 N 筆 audit 紀錄。
*
* @return array<int, array<string, mixed>>
*/
public static function recent( int $limit = 100, ?string $entity_type = null ): array {
global $wpdb;
$table = self::table_name();
if ( $entity_type ) {
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM `{$table}` WHERE entity_type = %s ORDER BY id DESC LIMIT %d",
$entity_type,
$limit
),
ARRAY_A
);
} else {
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d",
$limit
),
ARRAY_A
);
}
return is_array( $rows ) ? $rows : array();
}
public static function count(): int {
global $wpdb;
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::table_name() . '`' );
}
}
@@ -0,0 +1,134 @@
<?php
/**
* TMDO_Auto_Promoter — shadow_read 穩定期後自動升級到 aeav_only。
*
* 僅針對 `shadow_read` 做自動升級(升級到 aeav_only),其他 mode 不自動動。
* 判斷條件(皆滿足才觸發):
* 1. 當前 mode = shadow_read
* 2. 進入 shadow_read 距今 ≥ `min_days`
* 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0
*
* 這符合「穩定期沒問題就進 production」的安全遷移原則。
* 若 diff 出現,規則永不觸發;人工介入即可。
*
* 可由 option 或 filter 調整:
* option `wpdo_auto_promote_enabled` — 全域開關(bool,預設 false)
* option `wpdo_auto_promote_min_days` — 停留天數門檻(int,預設 7)
* filter `wpdo_auto_promote_should_run` — per-entity 覆寫(bool default)
*
* @package WP_Data_Optimizer
* @since 1.5.0
*/
// 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_Auto_Promoter {
public const OPT_ENABLED = 'wpdo_auto_promote_enabled';
public const OPT_MIN_DAYS = 'wpdo_auto_promote_min_days';
public const DEFAULT_MIN_DAYS = 7;
public const CRON_HOOK = 'wpdo_auto_promote_check';
public static function init(): void {
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
}
/**
* 首次啟用時排入 daily cron。Option 關閉時會停掉,開啟時重排。
*/
public static function maybe_schedule(): void {
$enabled = (bool) get_option( self::OPT_ENABLED, false );
$scheduled = wp_next_scheduled( self::CRON_HOOK );
if ( $enabled && ! $scheduled ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
} elseif ( ! $enabled && $scheduled ) {
wp_unschedule_event( $scheduled, self::CRON_HOOK );
}
}
/**
* 遍歷所有 entity,符合條件者升級為 aeav_only。
*
* @return array<string, string> entity_type → 結果('promoted'|'not_ready'|'no_diff_yet'|'disabled_globally')
*/
public static function check_all(): array {
$results = array();
if ( ! (bool) get_option( self::OPT_ENABLED, false ) ) {
foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) {
$results[ $type ] = 'disabled_globally';
}
return $results;
}
$min_days = (int) get_option( self::OPT_MIN_DAYS, self::DEFAULT_MIN_DAYS );
$entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) {
$results[ $type ] = self::check_entity( $type, $min_days, $entered_all );
}
return $results;
}
/**
* 評估單一 entity 是否可升級。
*
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'has_diff' | 'vetoed'
*/
public static function check_entity( string $type, int $min_days, array $entered_all ): string {
$current = TMDO_Mode_Manager::get( $type );
if ( $current !== TMDO_Mode_Manager::MODE_SHADOW_READ ) {
return 'not_shadow_read';
}
$entered_at = isset( $entered_all[ $type ] ) ? (int) $entered_all[ $type ] : 0;
if ( $entered_at === 0 || ( time() - $entered_at ) < $min_days * DAY_IN_SECONDS ) {
return 'not_ready';
}
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity();
$count = (int) ( $diff_counts[ $type ] ?? 0 );
if ( $count > 0 ) {
return 'has_diff';
}
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count );
if ( ! $should ) {
return 'vetoed';
}
$result = TMDO_Mode_Manager::set( $type, TMDO_Mode_Manager::MODE_AEAV_ONLY );
if ( is_wp_error( $result ) ) {
TMDO_Logger::error(
'auto_promote_failed',
array(
'entity_type' => $type,
'error' => $result->get_error_message(),
)
);
return 'error';
}
TMDO_Logger::info(
'auto_promote_success',
array(
'entity_type' => $type,
'entered_at' => gmdate( 'c', $entered_at ),
'days_elapsed' => (int) ( ( time() - $entered_at ) / DAY_IN_SECONDS ),
'min_days' => $min_days,
)
);
do_action( 'wpdo_auto_promoted', $type, $entered_at, $min_days );
return 'promoted';
}
}
@@ -0,0 +1,205 @@
<?php
/**
* TMDO_Cache_Orchestrator - 多層快取協調器
*
* 層級:
* L1: PHP Process Memory(同一請求內,靜態陣列)
* L2: WP Object Cache(搭配 Redis/Memcached 外掛時為跨請求)
* L3: Transientfallback,資料庫持久化)
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Cache_Orchestrator {
/** @var array<string, mixed> L1 記憶體快取 */
private static array $l1_cache = array();
/** @var int L1 快取計數上限(防記憶體爆炸) */
private const L1_MAX_ITEMS = 1000;
/** @var int L2 TTL 秒數 */
private const L2_TTL = HOUR_IN_SECONDS;
public static function init(): void {
// 註冊 L1 清理(避免長命令列任務記憶體膨脹)
add_action( 'shutdown', array( self::class, 'clear_l1' ) );
}
/**
* 產生快取 key
*/
private static function make_key( string $type, int $id, string $group ): string {
return "{$type}:{$id}:{$group}";
}
// ─────────────────────────────────────────────────────────
// 讀取
// ─────────────────────────────────────────────────────────
public static function get_row( string $type, int $id, string $group ) {
$key = self::make_key( $type, $id, $group );
// L1
if ( array_key_exists( $key, self::$l1_cache ) ) {
return self::$l1_cache[ $key ];
}
// L2
$cached = wp_cache_get( $key, TMDO_CACHE_GROUP );
if ( $cached !== false ) {
self::set_l1( $key, $cached );
return $cached;
}
return false;
}
// ─────────────────────────────────────────────────────────
// 寫入
// ─────────────────────────────────────────────────────────
public static function set_row( string $type, int $id, string $group, array $data ): void {
$key = self::make_key( $type, $id, $group );
self::set_l1( $key, $data );
wp_cache_set( $key, $data, TMDO_CACHE_GROUP, self::L2_TTL );
}
private static function set_l1( string $key, $data ): void {
// 防 L1 無限膨脹
if ( count( self::$l1_cache ) >= self::L1_MAX_ITEMS ) {
// 簡易 FIFO:砍掉最舊的 20%
$keep = (int) ( self::L1_MAX_ITEMS * 0.8 );
self::$l1_cache = array_slice( self::$l1_cache, - $keep, null, true );
}
self::$l1_cache[ $key ] = $data;
}
// ─────────────────────────────────────────────────────────
// 失效
// ─────────────────────────────────────────────────────────
public static function invalidate( string $type, int $id, string $group ): void {
$key = self::make_key( $type, $id, $group );
unset( self::$l1_cache[ $key ] );
wp_cache_delete( $key, TMDO_CACHE_GROUP );
}
public static function flush_entity( string $type, ?int $id = null ): void {
// 若沒給 id → flush 整個 entity type(L1 中所有該 type 的 key)
if ( $id === null ) {
$prefix = 'uae:' . $type . ':';
foreach ( array_keys( self::$l1_cache ) as $key ) {
if ( strpos( $key, $prefix ) === 0 ) {
unset( self::$l1_cache[ $key ] );
}
}
// Object cache 沒法做 prefix flush,用版本號 bump
wp_cache_set( 'wpdo_cache_version', time(), TMDO_CACHE_GROUP );
return;
}
$groups = TMDO_Entity_Registry::get_groups_for_type( $type );
foreach ( $groups as $group ) {
self::invalidate( $type, $id, $group );
}
}
public static function flush_all(): void {
self::$l1_cache = array();
// WP Object Cache 無跨群組 flush,改用版本號方式
wp_cache_set( 'wpdo_cache_version', time(), TMDO_CACHE_GROUP );
}
public static function clear_l1(): void {
self::$l1_cache = array();
}
// ─────────────────────────────────────────────────────────
// 批次預熱(核心效能優化:解決清單頁 N+1)
// ─────────────────────────────────────────────────────────
/**
* 批次預載一組 entity IDs 的資料至快取
* 用於清單頁渲染前呼叫,避免每個 item 都去查 DB
*
* @param string $type 實體類型
* @param array $ids entity ID 陣列
* @param string $group 欄位群組
*/
public static function warm_batch( string $type, array $ids, string $group ): void {
if ( empty( $ids ) ) {
return;
}
// 找出未快取的 IDs
$uncached_ids = array_filter(
$ids,
function ( $id ) use ( $type, $group ) {
return self::get_row( $type, (int) $id, $group ) === false;
}
);
if ( empty( $uncached_ids ) ) {
return;
}
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return;
}
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
$id_col = $adapter->get_entity_id_column();
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return;
}
$ids_in = implode( ',', array_map( 'intval', $uncached_ids ) );
$rows = $wpdb->get_results(
"SELECT * FROM `{$table}` WHERE `{$id_col}` IN ({$ids_in})",
ARRAY_A
);
if ( ! is_array( $rows ) ) {
return;
}
// 建立 id → row 索引
$indexed = array();
foreach ( $rows as $row ) {
$indexed[ (int) $row[ $id_col ] ] = $row;
}
// 填入快取(未找到者存空陣列避免重查)
foreach ( $uncached_ids as $id ) {
$id_int = (int) $id;
self::set_row( $type, $id_int, $group, $indexed[ $id_int ] ?? array() );
}
}
// ─────────────────────────────────────────────────────────
// 統計(供後台顯示)
// ─────────────────────────────────────────────────────────
public static function get_l1_stats(): array {
return array(
'count' => count( self::$l1_cache ),
'max' => self::L1_MAX_ITEMS,
'usage_pct' => self::L1_MAX_ITEMS > 0
? round( count( self::$l1_cache ) / self::L1_MAX_ITEMS * 100, 1 )
: 0,
);
}
}
@@ -0,0 +1,148 @@
<?php
/**
* TMDO_Conflict_Detector — 偵測 UAE 與 UAEPG 同欄位衝突
*
* 背景:
* UAEPG(PostgreSQL sidecar)以 metadata filter priority 5 攔截,
* UAE 在 priority 10 攔截。若同一 meta_key 被雙方都登錄,
* UAEPG 會先短路 return,UAE 寫入永遠不執行,資料靜默遺失。
*
* 本類別在欄位登錄階段完成後(init:25)掃描雙方 Registry,
* 找出 overlap 並:
* 1. 寫入 error log(供 CI / 監控系統消化)
* 2. 在管理後台顯示紅色 admin_notices
* 3. 提供 TMDO_Conflict_Detector::get_conflicts() 與 CLI 指令 wp uae conflicts 查詢
*
* 結果 cache 在 static property 內,單 request 內不重複掃描。
*
* @package WP_Data_Optimizer
* @since 1.1.2
*/
// 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_Conflict_Detector {
/**
* @var array<int, array{entity_type:string, meta_key:string, wpdo_group:string, uaepg_group:string}>|null
*/
private static ?array $cache = null;
public static function init(): void {
// init:25 — register_default_fields 於 init:20 觸發 wpdo_register_fields,
// UAEPG 亦在 init:20 觸發 uaepg_register_fields;此時 priority 25 可確保雙邊皆完成登錄。
add_action( 'init', array( self::class, 'scan' ), 25 );
if ( is_admin() ) {
add_action( 'admin_notices', array( self::class, 'maybe_render_admin_notice' ) );
}
}
/**
* 掃描並 cache 衝突清單。
*
* @return array<int, array{entity_type:string, meta_key:string, wpdo_group:string, uaepg_group:string}>
*/
public static function scan(): array {
if ( self::$cache !== null ) {
return self::$cache;
}
if ( ! class_exists( 'UAEPG_Registry' ) ) {
return self::$cache = array();
}
$conflicts = array();
foreach ( TMDO_Entity_Registry::get_all_fields() as $entity_type => $fields_by_key ) {
foreach ( $fields_by_key as $meta_key => $wpdo_field ) {
if ( ! UAEPG_Registry::is_managed( $entity_type, $meta_key ) ) {
continue;
}
$uaepg_field = UAEPG_Registry::get_field( $entity_type, $meta_key );
$conflicts[] = array(
'entity_type' => $entity_type,
'meta_key' => $meta_key,
'wpdo_group' => $wpdo_field['group'] ?? '(unknown)',
'uaepg_group' => $uaepg_field['group'] ?? '(unknown)',
);
}
}
if ( $conflicts ) {
TMDO_Logger::error(
'field_registration_conflict',
array(
'count' => count( $conflicts ),
'fields' => $conflicts,
)
);
}
return self::$cache = $conflicts;
}
/**
* 取得衝突清單(若尚未掃描,觸發掃描)。
*/
public static function get_conflicts(): array {
return self::$cache ?? self::scan();
}
/**
* 測試用:強制清除 cache,下次 scan() 會重跑。
*
* @internal
*/
public static function reset_cache(): void {
self::$cache = null;
}
/**
* 若有衝突則輸出管理後台紅色通知。
*/
public static function maybe_render_admin_notice(): void {
$conflicts = self::get_conflicts();
if ( ! $conflicts ) {
return;
}
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
$count = count( $conflicts );
$lines = array();
foreach ( array_slice( $conflicts, 0, 5 ) as $c ) {
$lines[] = sprintf(
'%s / %s(UAE 群組「%s」 ↔ UAEPG 群組「%s」)',
esc_html( $c['entity_type'] ),
esc_html( $c['meta_key'] ),
esc_html( $c['wpdo_group'] ),
esc_html( $c['uaepg_group'] )
);
}
$extra = $count > 5 ? sprintf( __( '… 另有 %d 個欄位未顯示', 'uae' ), $count - 5 ) : '';
printf(
'<div class="notice notice-error"><p><strong>%s</strong></p><ul style="list-style:disc;margin-left:20px;"><li>%s</li></ul>%s<p>%s <code>wp uae conflicts</code></p></div>',
esc_html(
sprintf(
/* translators: %d: conflict count */
__( 'UAE × UAEPG 欄位衝突偵測:發現 %d 個同 meta_key 被雙方 Registry 重複登錄 — UAEPG 會搶先短路,UAE 寫入將被靜默跳過', 'uae' ),
$count
)
),
// $lines elements were each individually esc_html()'d above (lines 121-124).
// implode joins escaped strings with literal HTML markers — safe.
implode( '</li><li>', $lines ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above.
$extra ? '<p><em>' . esc_html( $extra ) . '</em></p>' : '',
esc_html__( '查看完整清單:', 'uae' )
);
}
}
@@ -0,0 +1,329 @@
<?php
/**
* TMDO_Entity_Health — Entity Bridge 健康狀態聚合器
*
* 收集每個 entity type (user/term/comment) 的即時健康快照:
* - 模式與已停留天數
* - 每個群組的覆蓋率(flat table rows vs EAV rows
* - Shadow diff 計數
* - 遷移進度(斷點位置、已搬移筆數)
* - Auto-promote 資格評估
* - 操作建議
*
* @package WP_Data_Optimizer
* @since 2.6.6
*/
// 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,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions -- inherits engine coding standard.
defined( 'ABSPATH' ) || exit;
final class TMDO_Entity_Health {
/** Entity types managed by this class (post is handled by Feature_Flags FSM). */
public const MANAGED_TYPES = array( 'user', 'term', 'comment' );
/**
* 取得全部 entity 的健康快照(批次,供 REST polling 用)。
*
* @return array<string, array>
*/
public static function get_all(): array {
$result = array();
foreach ( self::MANAGED_TYPES as $type ) {
$result[ $type ] = self::get_one( $type );
}
return $result;
}
/**
* 取得單一 entity 的健康快照。
*/
public static function get_one( string $entity_type ): array {
$mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( $entity_type ) : 'disabled';
$entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
$entered_at = isset( $entered_all[ $entity_type ] ) ? (int) $entered_all[ $entity_type ] : 0;
$mode_days = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0;
$groups = self::get_groups_health( $entity_type );
$shadow_diffs = 0;
if ( class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity();
$shadow_diffs = (int) ( $diff_counts[ $entity_type ] ?? 0 );
}
$auto_promote = self::get_auto_promote_status( $entity_type, $mode, $entered_at, $shadow_diffs );
$backfill_active = self::is_backfill_active( $entity_type );
$native_counts = self::get_native_counts( $entity_type );
return array(
'entity_type' => $entity_type,
'mode' => $mode,
'entered_at' => $entered_at,
'mode_days' => $mode_days,
'groups' => $groups,
'shadow_diffs' => $shadow_diffs,
'auto_promote' => $auto_promote,
'backfill_active' => $backfill_active,
'native_entity_count' => $native_counts['entity_count'],
'native_meta_count' => $native_counts['meta_count'],
'native_entity_table' => $native_counts['entity_table'],
'native_meta_table' => $native_counts['meta_table'],
'recommendation' => self::get_recommendation( $entity_type, $mode, $groups, $shadow_diffs ),
'next_mode' => self::get_next_mode( $mode ),
'prev_mode' => self::get_prev_mode( $mode ),
);
}
/**
* 取得 native EAV 表的總筆數(entity 本體 + meta)。
*/
private static function get_native_counts( string $entity_type ): array {
global $wpdb;
$adapter = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_adapter( $entity_type ) : null;
if ( ! $adapter ) {
return array(
'entity_table' => '',
'meta_table' => '',
'entity_count' => 0,
'meta_count' => 0,
);
}
$meta_table = $adapter->get_native_meta_table();
$entity_table = self::get_native_entity_table( $entity_type );
$entity_count = $entity_table ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$entity_table}`" ) : 0;
$meta_count = $meta_table ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$meta_table}`" ) : 0;
return array(
'entity_table' => $entity_table,
'meta_table' => $meta_table,
'entity_count' => $entity_count,
'meta_count' => $meta_count,
);
}
/**
* 取得 entity 本體資料表名稱(非 meta 表)。
*/
private static function get_native_entity_table( string $entity_type ): string {
global $wpdb;
$map = array(
'user' => $wpdb->users,
'term' => $wpdb->terms,
'comment' => $wpdb->comments,
'post' => $wpdb->posts,
);
return $map[ $entity_type ] ?? '';
}
// ─────────────────────────────────────────────────────────
// 群組覆蓋率
// ─────────────────────────────────────────────────────────
private static function get_groups_health( string $entity_type ): array {
if ( ! class_exists( 'TMDO_Entity_Registry' ) || ! class_exists( 'TMDO_Schema_Manager' ) ) {
return array();
}
global $wpdb;
$groups = TMDO_Entity_Registry::get_groups_for_type( $entity_type );
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
$result = array();
foreach ( $groups as $group_name ) {
$fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
$table_exists = TMDO_Schema_Manager::table_exists( $table );
$flat_rows = 0;
$eav_rows = 0;
if ( $table_exists && $adapter ) {
$flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
$managed_keys = array_column( $fields, 'key' );
if ( ! empty( $managed_keys ) ) {
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$phs = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
$eav_rows = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$meta_table}` WHERE `meta_key` IN ({$phs})",
...$managed_keys
)
);
}
}
$coverage_pct = 0.0;
if ( $eav_rows > 0 ) {
$coverage_pct = round( min( $flat_rows / $eav_rows, 1.0 ) * 100, 1 );
} elseif ( $flat_rows > 0 ) {
$coverage_pct = 100.0;
}
$migration_info = self::get_migration_status( $entity_type, $group_name );
$result[] = array(
'name' => $group_name,
'fields_count' => count( $fields ),
'table' => $table,
'table_exists' => $table_exists,
'flat_rows' => $flat_rows,
'eav_rows' => $eav_rows,
'coverage_pct' => $coverage_pct,
'migration_status' => $migration_info['status'],
'last_id' => $migration_info['last_id'],
'total_migrated' => $migration_info['total_migrated'],
'started_at' => $migration_info['started_at'],
'completed_at' => $migration_info['completed_at'],
);
}
return $result;
}
private static function get_migration_status( string $entity_type, string $group_name ): array {
global $wpdb;
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT status, last_id, total_migrated, started_at, completed_at FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
$entity_type,
$group_name
),
ARRAY_A
);
return array(
'status' => $row['status'] ?? 'not_started',
'last_id' => (int) ( $row['last_id'] ?? 0 ),
'total_migrated' => (int) ( $row['total_migrated'] ?? 0 ),
'started_at' => $row['started_at'] ?? null,
'completed_at' => $row['completed_at'] ?? null,
);
}
// ─────────────────────────────────────────────────────────
// Auto-promote 資格評估
// ─────────────────────────────────────────────────────────
private static function get_auto_promote_status(
string $entity_type,
string $mode,
int $entered_at,
int $shadow_diffs
): array {
$enabled = (bool) get_option( TMDO_Auto_Promoter::OPT_ENABLED, false );
$min_days = (int) get_option( TMDO_Auto_Promoter::OPT_MIN_DAYS, TMDO_Auto_Promoter::DEFAULT_MIN_DAYS );
$days_elapsed = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0;
if ( $mode !== TMDO_Mode_Manager::MODE_SHADOW_READ ) {
return array(
'enabled' => $enabled,
'min_days' => $min_days,
'eligible' => false,
'reason' => 'not_shadow_read',
);
}
if ( $days_elapsed < $min_days ) {
return array(
'enabled' => $enabled,
'min_days' => $min_days,
'days_elapsed' => $days_elapsed,
'eligible' => false,
'reason' => "need_{$min_days}_days",
);
}
if ( $shadow_diffs > 0 ) {
return array(
'enabled' => $enabled,
'min_days' => $min_days,
'days_elapsed' => $days_elapsed,
'eligible' => false,
'reason' => "has_{$shadow_diffs}_diffs",
);
}
return array(
'enabled' => $enabled,
'min_days' => $min_days,
'days_elapsed' => $days_elapsed,
'eligible' => true,
'reason' => 'ready',
);
}
// ─────────────────────────────────────────────────────────
// 建議與模式轉換
// ─────────────────────────────────────────────────────────
private static function get_recommendation(
string $entity_type,
string $mode,
array $groups,
int $shadow_diffs
): string {
switch ( $mode ) {
case TMDO_Mode_Manager::MODE_DISABLED:
return '啟用 Hook Bus 並切換到 dual_write,讓系統開始對 flat table 雙寫。';
case TMDO_Mode_Manager::MODE_DUAL_WRITE:
foreach ( $groups as $g ) {
if ( $g['coverage_pct'] < 99.0 ) {
return '執行 Backfill 遷移歷史資料,待所有群組覆蓋率達到 100% 後再升級到 shadow_read。';
}
}
return '所有群組覆蓋率已達 100%,可以升級到 shadow_read 進行驗證期觀察。';
case TMDO_Mode_Manager::MODE_SHADOW_READ:
if ( $shadow_diffs > 0 ) {
return "發現 {$shadow_diffs} 筆 shadow diff,請先調查差異原因(Settings → Shadow Diffs)後再考慮升級。";
}
$min_days = (int) get_option( TMDO_Auto_Promoter::OPT_MIN_DAYS, TMDO_Auto_Promoter::DEFAULT_MIN_DAYS );
$entered_all = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
$entered_at = isset( $entered_all[ $entity_type ] ) ? (int) $entered_all[ $entity_type ] : 0;
$days = $entered_at > 0 ? (int) floor( ( time() - $entered_at ) / DAY_IN_SECONDS ) : 0;
if ( $days < $min_days ) {
return "繼續觀察中(已 {$days}/{$min_days} 天)。無 diff 且穩定後可升級到 aeav_only。";
}
return '已達穩定期,可以升級到 aeav_only(最終生產模式)。';
case TMDO_Mode_Manager::MODE_AEAV_ONLY:
return '遷移完成。所有讀寫均走 flat table,效能最佳。';
default:
return '';
}
}
private static function get_next_mode( string $mode ): ?string {
$idx = array_search( $mode, TMDO_Mode_Manager::ALL_MODES, true );
if ( $idx === false || $idx >= count( TMDO_Mode_Manager::ALL_MODES ) - 1 ) {
return null;
}
return TMDO_Mode_Manager::ALL_MODES[ $idx + 1 ];
}
private static function get_prev_mode( string $mode ): ?string {
$idx = array_search( $mode, TMDO_Mode_Manager::ALL_MODES, true );
if ( $idx === false || $idx <= 0 ) {
return null;
}
return TMDO_Mode_Manager::ALL_MODES[ $idx - 1 ];
}
private static function is_backfill_active( string $entity_type ): bool {
return (bool) wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type ) );
}
}
@@ -0,0 +1,799 @@
<?php
/**
* TMDO_Entity_Migration_Engine - 遷移引擎
*
* 負責將原生 wp_*meta 表中的 EAV 資料搬移至 UAE 扁平化表
*
* 特性:
* - Cursor-based 分頁(避免 OFFSET 效能問題)
* - 斷點續傳(記錄 last_id
* - 批次處理(可配置 batch size
* - Transaction 安全
* - 統計報告
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Entity_Migration_Engine {
/** 預設批次大小 */
public const DEFAULT_BATCH_SIZE = 500;
/** 批次間延遲(毫秒)*/
public const DEFAULT_SLEEP_MS = 100;
/**
* 遷移一個實體類型下的一個群組
*
* @param string $entity_type
* @param string $group_name
* @param array $options {
* @type int $batch_size 每批筆數
* @type int $sleep_ms 批次間延遲毫秒
* @type bool $resume 是否從斷點續傳
* @type bool $dry_run 乾跑(不實際寫入)
* }
* @return array 統計報告
*/
public static function migrate_group(
string $entity_type,
string $group_name,
array $options = array()
): array {
$defaults = array(
'batch_size' => self::DEFAULT_BATCH_SIZE,
'sleep_ms' => self::DEFAULT_SLEEP_MS,
'resume' => true,
'dry_run' => false,
);
$options = wp_parse_args( $options, $defaults );
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return self::error_result( "Adapter not found: {$entity_type}" );
}
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
if ( empty( $group_fields ) ) {
return self::error_result( "Group not registered: {$entity_type}/{$group_name}" );
}
$managed_keys = array_column( $group_fields, 'key' );
if ( empty( $managed_keys ) ) {
return self::error_result( 'No keys to migrate' );
}
$target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
if ( ! TMDO_Schema_Manager::table_exists( $target_table ) && ! $options['dry_run'] ) {
return self::error_result( "Target table does not exist: {$target_table}" );
}
// 斷點
$last_id = $options['resume']
? self::get_checkpoint( $entity_type, $group_name )
: 0;
// 標記開始
if ( ! $options['dry_run'] ) {
self::mark_migration_started( $entity_type, $group_name );
}
$stats = array(
'entity_type' => $entity_type,
'group' => $group_name,
'migrated' => 0,
'errors' => 0,
'skipped' => 0,
'elapsed_sec' => 0,
'dry_run' => $options['dry_run'],
);
$start_time = microtime( true );
global $wpdb;
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$field_map = array_column( $group_fields, null, 'key' );
// 建立 IN 子句佔位符
$keys_placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
do {
// Cursor-based 分頁
$query = $wpdb->prepare(
"SELECT DISTINCT `{$id_col}` FROM `{$meta_table}`
WHERE `meta_key` IN ({$keys_placeholders})
AND `{$id_col}` > %d
ORDER BY `{$id_col}` ASC
LIMIT %d",
...array_merge( $managed_keys, array( $last_id, $options['batch_size'] ) )
);
$entity_ids = $wpdb->get_col( $query );
if ( empty( $entity_ids ) ) {
break;
}
foreach ( $entity_ids as $entity_id ) {
$entity_id = (int) $entity_id;
try {
$migrated = self::migrate_single_entity(
$entity_type,
$group_name,
$entity_id,
$managed_keys,
$field_map,
$adapter,
$options['dry_run']
);
if ( $migrated ) {
++$stats['migrated'];
} else {
++$stats['skipped'];
}
} catch ( \Throwable $e ) {
++$stats['errors'];
error_log(
sprintf(
'[UAE Migration] Error %s/%s ID=%d: %s',
$entity_type,
$group_name,
$entity_id,
$e->getMessage()
)
);
}
$last_id = $entity_id;
}
// 更新斷點
if ( ! $options['dry_run'] ) {
self::update_checkpoint( $entity_type, $group_name, $last_id, $stats['migrated'] );
}
// 釋放記憶體
$wpdb->flush();
// 延遲(降低 DB 負載)
if ( $options['sleep_ms'] > 0 ) {
usleep( $options['sleep_ms'] * 1000 );
}
} while ( count( $entity_ids ) === $options['batch_size'] );
// 標記完成
if ( ! $options['dry_run'] ) {
self::mark_migration_completed( $entity_type, $group_name );
}
$stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 );
return $stats;
}
/**
* 遷移單一 entity 的所有 meta
*/
private static function migrate_single_entity(
string $entity_type,
string $group_name,
int $entity_id,
array $managed_keys,
array $field_map,
TMDO_Entity_Adapter_Interface $adapter,
bool $dry_run
): bool {
global $wpdb;
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
// 取出此 entity 所有相關的 meta
$placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
$metas = $wpdb->get_results(
$wpdb->prepare(
"SELECT `meta_key`, `meta_value` FROM `{$meta_table}`
WHERE `{$id_col}` = %d AND `meta_key` IN ({$placeholders})",
...array_merge( array( $entity_id ), $managed_keys )
),
ARRAY_A
);
if ( empty( $metas ) ) {
return false;
}
// 組裝 row 資料
$data = array( $id_col => $entity_id );
$formats = array( '%d' );
foreach ( $metas as $meta ) {
$key = $meta['meta_key'];
$value = $meta['meta_value'];
if ( ! isset( $field_map[ $key ] ) ) {
continue;
}
$field_def = $field_map[ $key ];
// 安全反序列化 — wp_usermeta 是使用者可寫表,攻擊者可植入序列化物件
// 觸發 __wakeup/__destruct gadget chain。allowed_classes=false 阻擋。
$value = self::safe_unserialize( $value );
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$data[ $col ] = TMDO_Type_Caster::to_db( $value, $field_def );
$formats[] = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
}
if ( count( $data ) <= 1 ) {
return false;
}
if ( $dry_run ) {
return true;
}
$target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
// REPLACE 作 Upsert
return $wpdb->replace( $target_table, $data, $formats ) !== false;
}
// ─────────────────────────────────────────────────────────
// 單批次非同步遷移(WP Cron 用)
// ─────────────────────────────────────────────────────────
/**
* 執行一個批次遷移並回傳進度(由 WP Cron 驅動,自動重排直到完成)。
*
* @param string $entity_type
* @param string $group_name
* @param int $batch_size
* @return array{done:bool,migrated:int,last_id:int,total:int,status:string,error?:string}
*/
public static function migrate_group_batch(
string $entity_type,
string $group_name,
int $batch_size = self::DEFAULT_BATCH_SIZE
): array {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return array(
'done' => true,
'migrated' => 0,
'last_id' => 0,
'total' => 0,
'status' => 'error',
'error' => "Adapter not found: {$entity_type}",
);
}
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
if ( empty( $group_fields ) ) {
return array(
'done' => true,
'migrated' => 0,
'last_id' => 0,
'total' => 0,
'status' => 'error',
'error' => "Group not registered: {$entity_type}/{$group_name}",
);
}
$managed_keys = array_column( $group_fields, 'key' );
$target_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
if ( ! TMDO_Schema_Manager::table_exists( $target_table ) ) {
return array(
'done' => true,
'migrated' => 0,
'last_id' => 0,
'total' => 0,
'status' => 'error',
'error' => "Target table not found: {$target_table}",
);
}
global $wpdb;
$status_table = self::get_status_table();
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT last_id, total_migrated FROM `{$status_table}` WHERE entity_type = %s AND group_name = %s",
$entity_type,
$group_name
),
ARRAY_A
);
$last_id = (int) ( $row['last_id'] ?? 0 );
$prev_total = (int) ( $row['total_migrated'] ?? 0 );
self::mark_migration_started( $entity_type, $group_name );
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$field_map = array_column( $group_fields, null, 'key' );
$keys_placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
$entity_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT `{$id_col}` FROM `{$meta_table}`
WHERE `meta_key` IN ({$keys_placeholders})
AND `{$id_col}` > %d
ORDER BY `{$id_col}` ASC
LIMIT %d",
...array_merge( $managed_keys, array( $last_id, $batch_size ) )
)
);
$batch_migrated = 0;
foreach ( $entity_ids as $entity_id ) {
$entity_id = (int) $entity_id;
try {
if ( self::migrate_single_entity( $entity_type, $group_name, $entity_id, $managed_keys, $field_map, $adapter, false ) ) {
++$batch_migrated;
}
} catch ( \Throwable $e ) {
error_log( sprintf( '[UAE Migration Batch] Error %s/%s ID=%d: %s', $entity_type, $group_name, $entity_id, $e->getMessage() ) );
}
$last_id = $entity_id;
}
$total = $prev_total + $batch_migrated;
$done = count( $entity_ids ) < $batch_size;
if ( $done ) {
self::mark_migration_completed( $entity_type, $group_name );
} else {
self::update_checkpoint( $entity_type, $group_name, $last_id, $total );
}
$wpdb->flush();
return array(
'done' => $done,
'migrated' => $batch_migrated,
'last_id' => $last_id,
'total' => $total,
'status' => $done ? 'completed' : 'running',
);
}
// ─────────────────────────────────────────────────────────
// 斷點管理
// ─────────────────────────────────────────────────────────
private static function get_status_table(): string {
global $wpdb;
return $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status';
}
public static function get_checkpoint( string $entity_type, string $group_name ): int {
global $wpdb;
$table = self::get_status_table();
$last_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT last_id FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
$entity_type,
$group_name
)
);
return (int) ( $last_id ?? 0 );
}
private static function update_checkpoint( string $entity_type, string $group_name, int $last_id, int $total ): void {
global $wpdb;
$table = self::get_status_table();
$wpdb->replace(
$table,
array(
'entity_type' => $entity_type,
'group_name' => $group_name,
'last_id' => $last_id,
'total_migrated' => $total,
'status' => 'running',
),
array( '%s', '%s', '%d', '%d', '%s' )
);
}
private static function mark_migration_started( string $entity_type, string $group_name ): void {
global $wpdb;
$table = self::get_status_table();
$existing = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
$entity_type,
$group_name
)
);
if ( $existing ) {
$wpdb->update(
$table,
array(
'status' => 'running',
'started_at' => current_time( 'mysql' ),
),
array( 'id' => $existing ),
array( '%s', '%s' ),
array( '%d' )
);
} else {
$wpdb->insert(
$table,
array(
'entity_type' => $entity_type,
'group_name' => $group_name,
'status' => 'running',
'started_at' => current_time( 'mysql' ),
),
array( '%s', '%s', '%s', '%s' )
);
}
}
private static function mark_migration_completed( string $entity_type, string $group_name ): void {
global $wpdb;
$table = self::get_status_table();
$wpdb->update(
$table,
array(
'status' => 'completed',
'completed_at' => current_time( 'mysql' ),
),
array(
'entity_type' => $entity_type,
'group_name' => $group_name,
),
array( '%s', '%s' ),
array( '%s', '%s' )
);
}
public static function reset_checkpoint( string $entity_type, string $group_name ): void {
global $wpdb;
$wpdb->delete(
self::get_status_table(),
array(
'entity_type' => $entity_type,
'group_name' => $group_name,
),
array( '%s', '%s' )
);
}
// ─────────────────────────────────────────────────────────
// 驗證
// ─────────────────────────────────────────────────────────
/**
* 驗證遷移資料完整性(抽樣比對)
*/
public static function verify(
string $entity_type,
string $group_name,
int $sample_size = 100
): array {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return array( 'error' => 'Adapter not found' );
}
$wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
if ( ! TMDO_Schema_Manager::table_exists( $wpdo_table ) ) {
return array( 'error' => "UAE table not found: {$wpdo_table}" );
}
// 隨機抽樣 UAE 表中的 IDs
$sample_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT `{$id_col}` FROM `{$wpdo_table}` ORDER BY RAND() LIMIT %d",
$sample_size
)
);
if ( empty( $sample_ids ) ) {
return array(
'sampled' => 0,
'match' => 0,
'mismatch' => 0,
);
}
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
$field_map = array_column( $group_fields, null, 'key' );
$managed_keys = array_column( $group_fields, 'key' );
$match = 0;
$mismatch = 0;
$details = array();
foreach ( $sample_ids as $entity_id ) {
$entity_id = (int) $entity_id;
// 取 UAE 資料
$wpdo_row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM `{$wpdo_table}` WHERE `{$id_col}` = %d",
$entity_id
),
ARRAY_A
);
// 取原始 meta
$placeholders = implode( ',', array_fill( 0, count( $managed_keys ), '%s' ) );
$raw_metas = $wpdb->get_results(
$wpdb->prepare(
"SELECT `meta_key`, `meta_value` FROM `{$meta_table}`
WHERE `{$id_col}` = %d AND `meta_key` IN ({$placeholders})",
...array_merge( array( $entity_id ), $managed_keys )
),
ARRAY_A
);
$is_match = true;
foreach ( $raw_metas as $raw ) {
$key = $raw['meta_key'];
if ( ! isset( $field_map[ $key ] ) ) {
continue;
}
$raw_value = self::safe_unserialize( $raw['meta_value'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$field_def = $field_map[ $key ];
$wpdo_value = TMDO_Type_Caster::from_db( $wpdo_row[ $col ] ?? null, $field_def );
// v2.1.6: type-aware comparison. The previous `(string)` cast falsely
// flagged numeric-precision differences (`"5678.90" !== "5678.9"`)
// even though both are numerically equal. Aligned with
// TMDO_Shadow_Diff_Logger::values_equal() semantics.
if ( ! self::loose_equal( $raw_value, $wpdo_value, $field_def['type'] ?? 'text' ) ) {
$is_match = false;
$details[] = array(
'entity_id' => $entity_id,
'key' => $key,
'raw' => $raw_value,
'uae' => $wpdo_value,
);
break;
}
}
$is_match ? $match++ : $mismatch++;
}
return array(
'sampled' => count( $sample_ids ),
'match' => $match,
'mismatch' => $mismatch,
'match_rate_pct' => count( $sample_ids ) > 0
? round( $match / count( $sample_ids ) * 100, 2 )
: 0,
'mismatch_details' => array_slice( $details, 0, 10 ),
);
}
// ─────────────────────────────────────────────────────────
// 回溯(UAE → wp_*meta 反向遷移)
// ─────────────────────────────────────────────────────────
/**
* 反向遷移:將 UAE 表資料寫回 wp_*meta
* 用於解除安裝前的資料保留
*/
public static function rollback_group(
string $entity_type,
string $group_name,
array $options = array()
): array {
$defaults = array(
'batch_size' => 500,
'sleep_ms' => 50,
);
$options = wp_parse_args( $options, $defaults );
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return self::error_result( "Adapter not found: {$entity_type}" );
}
$wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
$field_map = array_column( $group_fields, null, 'key' );
$stats = array(
'written' => 0,
'errors' => 0,
);
$last_id = 0;
do {
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM `{$wpdo_table}` WHERE `{$id_col}` > %d ORDER BY `{$id_col}` ASC LIMIT %d",
$last_id,
$options['batch_size']
),
ARRAY_A
);
if ( empty( $rows ) ) {
break;
}
foreach ( $rows as $row ) {
$entity_id = (int) $row[ $id_col ];
foreach ( $field_map as $key => $field_def ) {
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$value = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field_def );
if ( $value === null ) {
continue;
}
// 直接寫 wp_*meta,繞過 UAE 攔截(因為我們正在反向遷移)
$wpdb->replace(
$meta_table,
array(
$id_col => $entity_id,
'meta_key' => $key,
'meta_value' => maybe_serialize( $value ),
),
array( '%d', '%s', '%s' )
);
++$stats['written'];
}
$last_id = $entity_id;
}
$wpdb->flush();
usleep( $options['sleep_ms'] * 1000 );
} while ( count( $rows ) === $options['batch_size'] );
return $stats;
}
private static function error_result( string $message ): array {
return array(
'error' => $message,
'migrated' => 0,
'errors' => 1,
);
}
/**
* Object-safe replacement for `maybe_unserialize()`.
*
* `wp_usermeta` / `wp_postmeta` rows can contain attacker-planted serialized
* objects. `maybe_unserialize()` calls `unserialize()` with default options,
* which materializes objects and triggers `__wakeup`/`__destruct` gadgets.
* During backfill the migration engine runs in admin context, so any gadget
* chain in vendor/ becomes RCE.
*
* This wrapper passes `allowed_classes => false` so PHP returns
* `__PHP_Incomplete_Class` instances without ever invoking magic methods on
* the original class. We then convert those to null so they cannot leak
* into a flat-table column.
*
* @param mixed $value Raw meta_value from native EAV table.
* @return mixed Unserialized array/scalar, or original string if not serialized.
*/
private static function safe_unserialize( $value ) {
if ( ! is_string( $value ) ) {
return $value;
}
$trimmed = trim( $value );
// Cheap inline detector — does not rely on WP's is_serialized() so this
// function works in CLI / standalone migration contexts. Mirrors the
// shape checks WP does: type-tag at offset 0, ':' at offset 1, plausible
// terminator. PHP serialize tokens are: a (array), O (object),
// s (string), i (int), d (float), b (bool), N; (null).
if ( 'N;' !== $trimmed ) {
if ( strlen( $trimmed ) < 4 || ':' !== ( $trimmed[1] ?? '' ) ) {
return $value;
}
if ( ! in_array( $trimmed[0] ?? '', array( 'a', 'O', 's', 'i', 'd', 'b' ), true ) ) {
return $value;
}
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- explicit allowed_classes=false hardens against object injection.
$result = @unserialize( $trimmed, array( 'allowed_classes' => false ) );
if ( false === $result && 'b:0;' !== $trimmed ) {
return $value;
}
// Strip __PHP_Incomplete_Class artefacts (allowed_classes=false replaces
// any object marker with this stub). They must never reach a flat-table
// JSON column or a downstream consumer.
if ( is_object( $result ) ) {
return null;
}
if ( is_array( $result ) ) {
array_walk_recursive(
$result,
static function ( &$v ) {
if ( is_object( $v ) ) {
$v = null;
}
}
);
}
return $result;
}
/**
* Type-aware loose equality check for verify() sample comparison.
*
* Mirrors TMDO_Shadow_Diff_Logger::values_equal() so the two divergence
* detection paths (cron verify + live shadow_compare) agree on what
* constitutes a real mismatch vs a representation difference.
*
* @param mixed $eav Native wp_*meta value (after maybe_unserialize).
* @param mixed $flat Value from the WPDO flat table (after type cast).
* @param string $type Field type from registry (text/integer/decimal/...).
* @return bool
*/
private static function loose_equal( $eav, $flat, string $type ): bool {
if ( $eav === $flat ) {
return true;
}
if ( in_array( $type, array( 'integer', 'decimal' ), true ) ) {
return is_numeric( $eav ) && is_numeric( $flat ) && (float) $eav === (float) $flat;
}
if ( 'boolean' === $type ) {
return (bool) $eav === (bool) $flat;
}
if ( 'json' === $type || is_array( $eav ) || is_array( $flat ) ) {
return wp_json_encode( $eav ) === wp_json_encode( $flat );
}
return (string) $eav === (string) $flat;
}
/**
* 取得所有遷移狀態
*/
public static function get_all_statuses(): array {
global $wpdb;
$table = self::get_status_table();
return $wpdb->get_results( "SELECT * FROM `{$table}` ORDER BY entity_type, group_name", ARRAY_A ) ?: array();
}
}
@@ -0,0 +1,226 @@
<?php
/**
* TMDO_Entity_Registry - 全域欄位與適配器登錄中心
*
* 提供:
* - 適配器註冊(post/user/term/comment
* - 欄位群組註冊(Schema 定義)
* - 欄位查詢 APImeta_key → field_def
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Entity_Registry {
/** @var array<string, TMDO_Entity_Adapter_Interface> 實體類型 → 適配器實例 */
private static array $adapters = array();
/** @var array<string, array<string, array>> 實體類型 → 群組名 → 欄位定義陣列 */
private static array $groups = array();
/** @var array<string, array<string, array>> 實體類型 → meta_key → 欄位定義(含 group 資訊)*/
private static array $field_index = array();
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
private static array $pending_schemas = array();
/** 合法的欄位型別 */
public const VALID_TYPES = array(
'text',
'textarea',
'integer',
'decimal',
'boolean',
'date',
'datetime',
'timestamp',
'json',
'enum',
'binary',
);
/** 合法的實體類型 */
public const VALID_ENTITY_TYPES = array( 'post', 'user', 'term', 'comment' );
public static function init(): void {
self::$adapters = array();
self::$groups = array();
self::$field_index = array();
self::$pending_schemas = array();
}
// ─────────────────────────────────────────────────────────
// 適配器管理
// ─────────────────────────────────────────────────────────
public static function register_adapter( string $entity_type, TMDO_Entity_Adapter_Interface $adapter ): void {
if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) {
return;
}
self::$adapters[ $entity_type ] = $adapter;
}
public static function get_adapter( string $entity_type ): ?TMDO_Entity_Adapter_Interface {
return self::$adapters[ $entity_type ] ?? null;
}
public static function get_all_adapters(): array {
return self::$adapters;
}
// ─────────────────────────────────────────────────────────
// 欄位群組登錄
// ─────────────────────────────────────────────────────────
/**
* 登錄一組 UAE 管理的欄位
*
* @param string $entity_type post|user|term|comment
* @param string $group_name 群組名稱(會成為表名的一部分)
* @param array $fields 欄位定義陣列,每項需有 key、type,選填:
* - required (bool)
* - default (mixed)
* - searchable (bool) 加索引
* - fulltext (bool) 全文索引(僅 text/textarea
* - unique (bool) 唯一索引
* - options (array) enum 選項
* - label (string) 顯示名稱
*
* @return bool
*/
public static function register_group( string $entity_type, string $group_name, array $fields ): bool {
if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) {
return false;
}
if ( ! isset( self::$adapters[ $entity_type ] ) ) {
return false;
}
// 防重複登錄
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
return false;
}
// 驗證並正規化欄位定義
$normalized = array();
foreach ( $fields as $field ) {
if ( empty( $field['key'] ) || empty( $field['type'] ) ) {
continue;
}
if ( ! in_array( $field['type'], self::VALID_TYPES, true ) ) {
continue;
}
$normalized_field = wp_parse_args(
$field,
array(
'key' => '',
'type' => 'text',
'required' => false,
'default' => null,
'searchable' => false,
'fulltext' => false,
'unique' => false,
'options' => array(),
'label' => '',
)
);
$normalized_field['group'] = $group_name;
$normalized_field['entity_type'] = $entity_type;
$normalized[] = $normalized_field;
// 建立索引以供快速查詢
self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field;
}
if ( empty( $normalized ) ) {
return false;
}
self::$groups[ $entity_type ][ $group_name ] = $normalized;
// 加入待建表佇列
self::$pending_schemas[] = array(
'type' => $entity_type,
'group' => $group_name,
'fields' => $normalized,
);
return true;
}
// ─────────────────────────────────────────────────────────
// 查詢 API
// ─────────────────────────────────────────────────────────
/**
* 依 meta_key 查詢欄位定義
*
* @return array|null 欄位定義,或 null(非 UAE 管理欄位)
*/
public static function get_field( string $entity_type, string $meta_key ): ?array {
return self::$field_index[ $entity_type ][ $meta_key ] ?? null;
}
/**
* 取得實體類型下所有群組名稱
*
* @return array<string>
*/
public static function get_groups_for_type( string $entity_type ): array {
return array_keys( self::$groups[ $entity_type ] ?? array() );
}
/**
* 取得特定群組的完整欄位定義
*/
public static function get_group_fields( string $entity_type, string $group_name ): array {
return self::$groups[ $entity_type ][ $group_name ] ?? array();
}
/**
* 取得一個群組中所有 meta_key
*
* @return array<string>
*/
public static function get_group_keys( string $entity_type, string $group_name ): array {
$fields = self::get_group_fields( $entity_type, $group_name );
return array_column( $fields, 'key' );
}
/**
* 取得所有已登錄的欄位(跨實體、跨群組)
*/
public static function get_all_fields(): array {
return self::$field_index;
}
/**
* 取得待建表清單(供 Schema_Manager 處理)
*/
public static function get_pending_schemas(): array {
return self::$pending_schemas;
}
public static function clear_pending_schemas(): void {
self::$pending_schemas = array();
}
/**
* 判斷某個 meta_key 是否由 UAE 管理
*/
public static function is_managed( string $entity_type, string $meta_key ): bool {
return isset( self::$field_index[ $entity_type ][ $meta_key ] );
}
}
+658
View File
@@ -0,0 +1,658 @@
<?php
/**
* TMDO_Hook_Bus - 統一 Hook 攔截匯流排
*
* 所有 WordPress 的 meta 操作皆通過此匯流排:
* - {type}_metadata 系列 filteradd/get/update/delete
* - 實體刪除 action
* - 原生查詢擴充 hook
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Hook_Bus {
/** 標記:防止在內部 UPSERT 時遞迴觸發 filter */
private static array $internal_ops = array();
public static function init(): void {
// 取得所有已註冊的適配器
$adapters = TMDO_Entity_Registry::get_all_adapters();
foreach ( $adapters as $type => $adapter ) {
self::register_hooks_for_type( $type, $adapter );
}
}
private static function register_hooks_for_type( string $type, TMDO_Entity_Adapter_Interface $adapter ): void {
// ── 寫入攔截 ──────────────────────────────────────────
add_filter( "update_{$type}_metadata", array( self::class, 'intercept_update' ), 10, 5 );
add_filter( "add_{$type}_metadata", array( self::class, 'intercept_add' ), 10, 5 );
// ── 讀取攔截 ──────────────────────────────────────────
add_filter( "get_{$type}_metadata", array( self::class, 'intercept_get' ), 10, 5 );
// ── 刪除攔截 ──────────────────────────────────────────
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
// ── 實體刪除時自動清理 ───────────────────────────────
add_action(
$adapter->get_delete_hook(),
function ( $entity_id ) use ( $type, $adapter ) {
self::cleanup_entity( $type, (int) $entity_id );
},
10,
1
);
}
// ─────────────────────────────────────────────────────────
// 寫入:update_{type}_metadata filter
// ─────────────────────────────────────────────────────────
/**
* 短路 WordPress 原生 update_metadata() 流程
*
* @param null|bool $check 若回傳 null 則 WP 繼續原生流程
* @param int $object_id
* @param string $meta_key
* @param mixed $meta_value
* @param mixed $prev_value
*/
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
return $check;
}
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( ! $field_def ) {
return $check; // 非管理欄位,放行
}
// ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
// dual_write: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
// shadow_read: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
return $check; // disabled
}
// Route decision (v1.2.0):讓 UAEPG 等外掛正式訂閱 routing,不必搶 priority 5。
$route = self::decide_route( 'update', $type, (int) $object_id, $meta_key, $meta_value );
if ( $route === 'pg' ) {
// 讓其他 listener(例如 UAEPG)接手;原生 EAV 也放行。
return null;
}
if ( $route === 'skip' ) {
// 不寫 flat、不寫 EAV,但告訴 WP 已處理。
return true;
}
// 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' );
$flat_result = self::perform_upsert( $type, (int) $object_id, $meta_key, $meta_value, $field_def );
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
// 若 mode 也要寫 EAV → return null 讓 WP 繼續
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null; // dual_write / shadow_read
}
return $flat_result; // aeav_only
}
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
return $check;
}
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( ! $field_def ) {
return $check;
}
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
return $check;
}
$route = self::decide_route( 'add', $type, (int) $object_id, $meta_key, $meta_value );
if ( $route === 'pg' ) {
return null;
}
if ( $route === 'skip' ) {
return true;
}
// UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' );
$flat_result = self::perform_upsert( $type, (int) $object_id, $meta_key, $meta_value, $field_def );
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
return $flat_result;
}
/**
* 如 filter `wpdo_capture_before_value` 回 true 才讀 flat table 的 before value,
* 否則直接回 null — 讓沒在用 audit / 其他 listener 的站台省一次 DB read。
*
* filter 參數:(bool $default_true, string $type, string $meta_key, string $op)
* $op ∈ { 'add', 'update', 'delete' }
*
* 使用範例(關閉 audit 的站台):
* add_filter( 'wpdo_capture_before_value', '__return_false' );
*
* @since 1.3.2
*/
private static function maybe_read_before_value(
string $type,
int $entity_id,
string $meta_key,
array $field_def,
string $op
) {
$capture = apply_filters(
'wpdo_capture_before_value',
true,
$type,
$meta_key,
$op
);
if ( ! $capture ) {
return null;
}
return self::read_flat_value( $type, $entity_id, $meta_key, $field_def );
}
/**
* 讀取 flat table 中當前值(before value,供 audit / after_write listener 使用)。
*
* 此方法**不經 cache 加熱**,直接查 DB,以避免快取污染與遞迴。表不存在回 null。
*
* @since 1.3.1
*/
private static function read_flat_value(
string $type,
int $entity_id,
string $meta_key,
array $field_def
) {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$group = $field_def['group'] ?? '';
if ( $group === '' ) {
return null;
}
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return null;
}
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
$id_col = $adapter->get_entity_id_column();
$raw = $wpdb->get_var(
$wpdb->prepare(
"SELECT `{$col}` FROM `{$table}` WHERE `{$id_col}` = %d LIMIT 1",
$entity_id
)
);
return $raw === null ? null : TMDO_Type_Caster::from_db( $raw, $field_def );
}
/**
* 讓外部 listener(例如 UAEPG)透過 `wpdo_route_decision` filter 指定路由。
*
* 回傳值:
* 'flat' (預設) — UAE 寫入 MySQL flat table
* 'pg' — 放行,由其他 listener 接手;UAE 不寫 flat,原生 EAV 依 mode 決定
* 'skip' — 都不寫(用於軟刪除之類特殊情境),但告訴 WP 已處理
*
* 其他非預期值會被 fallback 到 'flat' 以維持安全預設。
*
* @since 1.2.0
*/
private static function decide_route(
string $op,
string $type,
int $object_id,
string $meta_key,
$meta_value
): string {
$route = apply_filters(
'wpdo_route_decision',
'flat',
$type,
$object_id,
$meta_key,
$meta_value,
$op
);
if ( in_array( $route, array( 'flat', 'pg', 'skip' ), true ) ) {
return $route;
}
TMDO_Logger::warning(
'wpdo_route_decision_invalid_return',
array(
'returned' => is_scalar( $route ) ? (string) $route : gettype( $route ),
'op' => $op,
'type' => $type,
'key' => $meta_key,
)
);
return 'flat';
}
/**
* 執行 Upsert 操作
*/
private static function perform_upsert(
string $type,
int $entity_id,
string $meta_key,
$meta_value,
array $field_def
) {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$group = $field_def['group'];
$table = TMDO_Schema_Manager::get_table_name( $type, $group );
$id_col = $adapter->get_entity_id_column();
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
// 表不存在則讓 WP 走原生流程(降級處理)
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return null;
}
// 型別轉換
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
// 鎖防遞迴
$lock_key = $entity_id . ':' . $meta_key;
self::$internal_ops[ $lock_key ] = true;
try {
// 檢查列是否存在
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM `{$table}` WHERE `{$id_col}` = %d",
$entity_id
)
);
if ( $exists ) {
// UPDATE
$result = $wpdb->update(
$table,
array( $col => $db_value ),
array( $id_col => $entity_id ),
array( $format ),
array( '%d' )
);
} else {
// INSERT
$result = $wpdb->insert(
$table,
array(
$id_col => $entity_id,
$col => $db_value,
),
array( '%d', $format )
);
}
// 清除快取
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
// 短路回傳 true(WP 認為寫入成功)
return $result !== false;
} finally {
unset( self::$internal_ops[ $lock_key ] );
}
}
// ─────────────────────────────────────────────────────────
// 讀取:get_{type}_metadata filter
// ─────────────────────────────────────────────────────────
/**
* 攔截 get_metadata() 呼叫
*
* @param null|mixed $check 若回傳 null 則 WP 繼續原生流程
* @param int $object_id
* @param string $meta_key 空字串表示取所有 meta
* @param bool $single
* @param string $meta_type 5.5+ 額外參數
*/
public static function intercept_get( $check, $object_id, $meta_key, $single, $meta_type = '' ) {
$type = $meta_type ?: self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
// 空 keyWP 要求所有 meta,UAE 不攔截(維持相容性)
if ( $meta_key === '' ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( ! $field_def ) {
return $check;
}
// ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff
// aeav_only : 讀取走 UAE flat,不讀 EAV
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
return $check; // disabled / dual_write
}
$group = $field_def['group'];
$row = self::get_or_load_row( $type, (int) $object_id, $group );
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
$has_value = is_array( $row ) && array_key_exists( $col, $row );
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
// Shadow-read:與 EAV 比對,記錄差異
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
try {
TMDO_Shadow_Diff_Logger::compare_and_log(
$type,
(int) $object_id,
$meta_key,
$value,
$field_def
);
} catch ( \Throwable $e ) {
// 比對失敗不該影響讀取
TMDO_Logger::error(
'shadow_compare_exception',
array(
'error' => $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 ) {
// Safety cap: refuse mass-null if affected row count exceeds threshold.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
if ( $row_count > 500 ) {
TMDO_Logger::warning(
'intercept_delete_mass_blocked',
array(
'table' => $table,
'col' => $col,
'rows' => $row_count,
)
);
return false;
}
TMDO_Logger::info(
'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 );
}
}
+380
View File
@@ -0,0 +1,380 @@
<?php
/**
* TMDO_Mode_Manager - Per-entity-type bridge 模式管理
*
* 4 種運作模式(每個 entity type 可獨立設定):
*
* disabled — 完全停用 UAE hook,回到原生 wp_*meta EAV。
* Kill-switch,緊急關閉用。
*
* dual_write — 寫入:同時寫 UAE flat table + wp_*meta
* 讀取:走 wp_*meta(原生)
* 用途:遷移前期安全模式,UAE 開始累積資料但不影響讀取
*
* shadow_read — 寫入:同時寫 UAE flat table + wp_*meta
* 讀取:走 UAE flat table,同時比對 wp_*meta 記錄差異
* 用途:驗證期,確認 UAE 資料正確後才進入 aeav_only
*
* aeav_only — 寫入:只寫 UAE flat table
* 讀取:只讀 UAE flat table
* 用途:完成遷移後的最終模式,效能最佳
*
* 合法轉換路徑(安全性):
*
* disabled ←→ dual_write ←→ shadow_read ←→ aeav_only
* ↑ ↑
* └──── aeav_only 可以直接降級到任一前向狀態
*
* 禁止:disabled → shadow_read 或 disabled → aeav_only
* (會讀不到資料,因為 UAE 表還沒有任何寫入)
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Mode_Manager {
public const MODE_DISABLED = 'disabled';
public const MODE_DUAL_WRITE = 'dual_write';
public const MODE_SHADOW_READ = 'shadow_read';
public const MODE_AEAV_ONLY = 'aeav_only';
public const ALL_MODES = array(
self::MODE_DISABLED,
self::MODE_DUAL_WRITE,
self::MODE_SHADOW_READ,
self::MODE_AEAV_ONLY,
);
/** wp_options key holding array<entity_type, mode> */
private const OPT_KEY = 'wpdo_bridge_modes';
/**
* Per-entity 進入當前 mode 的 unix timestamp(v1.5.0+ 供 auto-promoter 使用)。
* Shape: array<string entity_type, int timestamp>
*/
public const OPT_ENTERED_AT = 'wpdo_bridge_mode_entered_at';
/** Per-request memoization(避免每次 filter 都查 DB option) */
private static ?array $cache = null;
/**
* 預設模式(v2.5.4 起):
* post → disabled(由 Legacy Feature_Flags FSM 管理,不由 Mode_Manager 控制)
* user / term / comment → dual_write(安全雙寫:寫入 flat table + 原生 EAV,讀取仍走 EAV
*/
private static function defaults(): array {
return array(
'post' => self::MODE_DISABLED,
'user' => self::MODE_DUAL_WRITE,
'term' => self::MODE_DUAL_WRITE,
'comment' => self::MODE_DUAL_WRITE,
);
}
/**
* 取得所有 entity 的 mode
*
* @return array<string, string>
*/
public static function all(): array {
if ( self::$cache !== null ) {
return self::$cache;
}
$stored = get_option( self::OPT_KEY, array() );
$modes = self::defaults();
if ( is_array( $stored ) ) {
foreach ( $stored as $type => $mode ) {
if ( is_string( $type ) && self::is_valid_mode( $mode ) && in_array( $type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
$modes[ $type ] = $mode;
}
}
}
self::$cache = $modes;
return $modes;
}
/**
* 取得單一 entity type 的 mode
*/
public static function get( string $entity_type ): string {
$all = self::all();
return $all[ $entity_type ] ?? self::MODE_DISABLED;
}
/**
* 設定單一 entity type 的 mode(包含轉換安全檢查)
*
* @return true|\WP_Error
*/
public static function set( string $entity_type, string $new_mode ) {
if ( ! in_array( $entity_type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
return new \WP_Error( 'invalid_entity', "Invalid entity type: {$entity_type}" );
}
if ( ! self::is_valid_mode( $new_mode ) ) {
return new \WP_Error( 'invalid_mode', "Invalid mode: {$new_mode}" );
}
$current = self::get( $entity_type );
if ( $current === $new_mode ) {
return true; // no-op
}
// 驗證轉換安全性
if ( ! self::is_safe_transition( $current, $new_mode ) ) {
return new \WP_Error(
'unsafe_transition',
sprintf(
__( '不安全的模式轉換:%1$s → %2$s。建議路徑:disabled → dual_write → shadow_read → aeav_only', 'uae' ),
$current,
$new_mode
)
);
}
$all = self::all();
$all[ $entity_type ] = $new_mode;
update_option( self::OPT_KEY, $all, false );
self::$cache = $all;
// 記錄進入此 mode 的 timestamp — auto-promoter 用來判斷停留天數。
$entered = get_option( self::OPT_ENTERED_AT, array() );
if ( ! is_array( $entered ) ) {
$entered = array();
}
$entered[ $entity_type ] = time();
update_option( self::OPT_ENTERED_AT, $entered, false );
TMDO_Logger::info(
'bridge_mode_changed',
array(
'entity_type' => $entity_type,
'from' => $current,
'to' => $new_mode,
'user_id' => get_current_user_id(),
)
);
// 模式變更時清除所有相關快取
TMDO_Cache_Orchestrator::flush_entity( $entity_type );
do_action( 'wpdo_bridge_mode_changed', $entity_type, $new_mode, $current );
return true;
}
/**
* 緊急 kill-switch:全部 entity 瞬間切到 disabled
*
* 這是**安全轉換規則的例外** — 任何狀態都可以瞬間降到 disabled,
* 因為 EAV 資料一直存在(dual_write / shadow_read 都還寫 EAV),
* 只有 aeav_only 切到 disabled 才有資料遺失風險。
*
* 呼叫這個方法表示「出事了,先退回安全狀態」,EAV 可能不是最新的,
* 但至少系統不會壞。
*
* @return int 改變狀態的 entity 數量
*/
public static function emergency_disable_all(): int {
$all = self::all();
$changed = 0;
foreach ( $all as $type => $mode ) {
if ( $mode !== self::MODE_DISABLED ) {
$all[ $type ] = self::MODE_DISABLED;
++$changed;
TMDO_Logger::warning(
'bridge_emergency_disable',
array(
'entity_type' => $type,
'previous_mode' => $mode,
'user_id' => get_current_user_id(),
'is_aeav_only' => $mode === self::MODE_AEAV_ONLY,
'warning' => $mode === self::MODE_AEAV_ONLY
? 'CRITICAL: switching from aeav_only to disabled — EAV may be stale'
: 'Normal emergency fallback',
)
);
}
}
if ( $changed > 0 ) {
update_option( self::OPT_KEY, $all, false );
self::$cache = $all;
TMDO_Cache_Orchestrator::flush_all();
do_action( 'wpdo_bridge_emergency_disabled', $changed );
}
return $changed;
}
/**
* 設定全部 entity 到同一個 mode(批次操作,會做轉換檢查)
*
* @return array<string, true|\WP_Error> 逐 entity 的結果
*/
public static function set_all( string $mode ): array {
$results = array();
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $type ) {
$results[ $type ] = self::set( $type, $mode );
}
return $results;
}
/**
* Helper: 目前 mode 是否會寫入 flat table?
*/
public static function writes_to_flat( string $entity_type ): bool {
return in_array(
self::get( $entity_type ),
array(
self::MODE_DUAL_WRITE,
self::MODE_SHADOW_READ,
self::MODE_AEAV_ONLY,
),
true
);
}
/**
* Helper: 目前 mode 是否會讓 WP 原生 EAV 也被寫入?
*/
public static function writes_to_eav( string $entity_type ): bool {
return in_array(
self::get( $entity_type ),
array(
self::MODE_DISABLED,
self::MODE_DUAL_WRITE,
self::MODE_SHADOW_READ,
),
true
);
}
/**
* Helper: 目前 mode 是否從 flat table 讀取?
*/
public static function reads_from_flat( string $entity_type ): bool {
return in_array(
self::get( $entity_type ),
array(
self::MODE_SHADOW_READ,
self::MODE_AEAV_ONLY,
),
true
);
}
/**
* Helper: 是否要做 shadow 比對?
*/
public static function does_shadow_compare( string $entity_type ): bool {
return self::get( $entity_type ) === self::MODE_SHADOW_READ;
}
// ─────────────────────────────────────────────────────────
// 驗證
// ─────────────────────────────────────────────────────────
public static function is_valid_mode( $mode ): bool {
return is_string( $mode ) && in_array( $mode, self::ALL_MODES, true );
}
/**
* 轉換是否安全?
*
* 規則:
* - 相鄰的階梯移動:允許(正向或反向)
* - 跨階梯降級:允許(後面的 fallback,EAV 還在)
* - 跨階梯升級:禁止(下游資料可能還沒跟上)
*
* 階梯順序(index 越大 = 越「靠 UAE」):
* 0: disabled
* 1: dual_write
* 2: shadow_read
* 3: aeav_only
*
* 降級(index 變小)永遠安全(EAV 都還在,除了 aeav_only→disabled 一跳,
* 但那是刻意的 emergency 用法 — 見 emergency_disable_all)。
*
* 升級只允許 +1(disabled→dual_write、dual_write→shadow_read、shadow_read→aeav_only)。
*/
public static function is_safe_transition( string $from, string $to ): bool {
$order = array(
self::MODE_DISABLED => 0,
self::MODE_DUAL_WRITE => 1,
self::MODE_SHADOW_READ => 2,
self::MODE_AEAV_ONLY => 3,
);
if ( ! isset( $order[ $from ], $order[ $to ] ) ) {
return false;
}
$diff = $order[ $to ] - $order[ $from ];
// 升級:只允許 +1
if ( $diff > 0 ) {
return $diff === 1;
}
// 降級:一律允許(這是 fallback,資料都還在)
return true;
}
// ─────────────────────────────────────────────────────────
// 內部
// ─────────────────────────────────────────────────────────
/**
* 測試用:清除 memoization
*/
public static function reset_cache(): void {
self::$cache = null;
}
/**
* Human-readable label
*/
public static function label( string $mode ): string {
switch ( $mode ) {
case self::MODE_DISABLED:
return __( '停用(原生 EAV)', 'uae' );
case self::MODE_DUAL_WRITE:
return __( '雙寫', 'uae' );
case self::MODE_SHADOW_READ:
return __( '影子讀取(驗證中)', 'uae' );
case self::MODE_AEAV_ONLY:
return __( '僅 UAE(生產模式)', 'uae' );
default:
return $mode;
}
}
/**
* Mode 的說明文字(給 admin UI 用)
*/
public static function description( string $mode ): string {
switch ( $mode ) {
case self::MODE_DISABLED:
return __( '完全停用 UAE 攔截,使用 WordPress 原生 wp_*meta 表。安全 fallback。', 'uae' );
case self::MODE_DUAL_WRITE:
return __( '寫入 UAE + 原生 meta 表,讀取仍走原生。遷移前期的安全起點。', 'uae' );
case self::MODE_SHADOW_READ:
return __( '寫入雙寫,讀取走 UAE 並比對原生 meta。驗證資料一致性的階段。', 'uae' );
case self::MODE_AEAV_ONLY:
return __( '僅讀寫 UAE,不再寫入原生 meta 表。最終生產模式,效能最佳。', 'uae' );
default:
return '';
}
}
}
@@ -0,0 +1,322 @@
<?php
/**
* TMDO_Query_Compiler - 跨實體查詢編譯器
*
* 將 wpdo_meta_query 編譯為高效 SQL
* - 單次 JOIN UAE 表(而非原生 meta_query 多次 JOIN wp_*meta
* - 型別正確的比較(避免 CAST 開銷)
* - 命中索引
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Query_Compiler {
/** @var array 合法的 SQL 比較運算子 */
private const VALID_OPERATORS = array(
'=',
'!=',
'<>',
'>',
'>=',
'<',
'<=',
'LIKE',
'NOT LIKE',
'IN',
'NOT IN',
'BETWEEN',
'NOT BETWEEN',
'EXISTS',
'NOT EXISTS',
);
/**
* 注入 wpdo_meta_query 至 WP_Querypost 實體)
*
* @param WP_Query $query
* @param array $wpdo_meta_query 結構同 meta_query
*/
public static function inject_into_wp_query( WP_Query $query, array $wpdo_meta_query ): void {
$post_type = $query->get( 'post_type' );
if ( is_array( $post_type ) ) {
$post_type = $post_type[0] ?? 'post';
}
if ( empty( $post_type ) || $post_type === 'any' ) {
$post_type = 'post';
}
// 編譯 JOIN 與 WHERE
$compiled = self::compile( 'post', $wpdo_meta_query );
if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) {
return;
}
// 注入 posts_clauses filter
add_filter(
'posts_clauses',
function ( $clauses ) use ( $compiled ) {
global $wpdb;
if ( ! empty( $compiled['joins'] ) ) {
$clauses['join'] .= ' ' . implode( ' ', $compiled['joins'] );
}
if ( ! empty( $compiled['where'] ) ) {
$clauses['where'] .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')';
}
return $clauses;
},
10,
1
);
// wpdo_orderby 支援
$wpdo_orderby = $query->get( 'wpdo_orderby' );
if ( $wpdo_orderby ) {
$order_dir = strtoupper( $query->get( 'order' ) ?: 'DESC' );
if ( ! in_array( $order_dir, array( 'ASC', 'DESC' ), true ) ) {
$order_dir = 'DESC';
}
$wpdo_col = TMDO_Schema_Manager::sanitize_column_name( $wpdo_orderby );
// 找出該欄位所在的群組
$field_def = TMDO_Entity_Registry::get_field( 'post', $wpdo_orderby );
if ( $field_def ) {
$alias = 'wpdo_' . $field_def['group'];
add_filter(
'posts_orderby',
function ( $orderby ) use ( $alias, $wpdo_col, $order_dir ) {
return "`{$alias}`.`{$wpdo_col}` {$order_dir}";
},
10,
1
);
}
}
}
/**
* 注入至 WP_User_Query
*/
public static function inject_into_user_query( WP_User_Query $query, array $wpdo_meta_query ): void {
$compiled = self::compile( 'user', $wpdo_meta_query );
if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) {
return;
}
global $wpdb;
// 透過 reflection 存取 WP_User_Query 的 query_vars 來注入
$query_orderby = &$query->query_orderby;
$query_where = &$query->query_where;
$query_from = &$query->query_from;
if ( ! empty( $compiled['joins'] ) ) {
$query_from .= ' ' . implode( ' ', $compiled['joins'] );
}
if ( ! empty( $compiled['where'] ) ) {
$query_where .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')';
}
// wpdo_orderby
$wpdo_orderby = $query->get( 'wpdo_orderby' );
if ( $wpdo_orderby ) {
$field_def = TMDO_Entity_Registry::get_field( 'user', $wpdo_orderby );
if ( $field_def ) {
$order_dir = strtoupper( $query->get( 'order' ) ?: 'DESC' );
if ( ! in_array( $order_dir, array( 'ASC', 'DESC' ), true ) ) {
$order_dir = 'DESC';
}
$alias = 'wpdo_' . $field_def['group'];
$col = TMDO_Schema_Manager::sanitize_column_name( $wpdo_orderby );
$query_orderby = "ORDER BY `{$alias}`.`{$col}` {$order_dir}";
}
}
}
/**
* 注入至 get_terms() 的 clauses
*/
public static function inject_into_terms_clauses( array $clauses, array $wpdo_meta_query ): array {
$compiled = self::compile( 'term', $wpdo_meta_query );
if ( empty( $compiled['joins'] ) && empty( $compiled['where'] ) ) {
return $clauses;
}
if ( ! empty( $compiled['joins'] ) ) {
$clauses['join'] .= ' ' . implode( ' ', $compiled['joins'] );
}
if ( ! empty( $compiled['where'] ) ) {
$clauses['where'] .= ' AND (' . implode( ' AND ', $compiled['where'] ) . ')';
}
return $clauses;
}
/**
* 核心編譯邏輯
*
* @param string $entity_type
* @param array $meta_query 結構範例:
* [
* 'relation' => 'AND',
* [ 'key' => 'price', 'value' => [100, 500], 'compare' => 'BETWEEN' ],
* [ 'key' => 'stock', 'value' => 'instock' ],
* ]
* @return array{joins: array<string>, where: array<string>}
*/
public static function compile( string $entity_type, array $meta_query ): array {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return array(
'joins' => array(),
'where' => array(),
);
}
$relation = strtoupper( $meta_query['relation'] ?? 'AND' );
if ( ! in_array( $relation, array( 'AND', 'OR' ), true ) ) {
$relation = 'AND';
}
unset( $meta_query['relation'] );
$needed_groups = array(); // group → true
$where_clauses = array();
foreach ( $meta_query as $clause ) {
if ( ! is_array( $clause ) || empty( $clause['key'] ) ) {
continue;
}
$field_def = TMDO_Entity_Registry::get_field( $entity_type, $clause['key'] );
if ( ! $field_def ) {
// 非 UAE 管理的欄位,跳過(讓原生 meta_query 接手)
continue;
}
$group = $field_def['group'];
$needed_groups[ $group ] = true;
$compare = strtoupper( $clause['compare'] ?? '=' );
if ( ! in_array( $compare, self::VALID_OPERATORS, true ) ) {
$compare = '=';
}
$alias = 'wpdo_' . $group;
$col = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] );
$value = $clause['value'] ?? null;
$where_clauses[] = self::build_comparison( $alias, $col, $compare, $value, $field_def );
}
// 建立 JOIN
$joins = array();
$prim_table = $adapter->get_primary_table();
$prim_id = $adapter->get_primary_id_column();
$id_col = $adapter->get_entity_id_column();
foreach ( array_keys( $needed_groups ) as $group ) {
$wpdo_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
if ( ! TMDO_Schema_Manager::table_exists( $wpdo_table ) ) {
continue;
}
$alias = 'wpdo_' . $group;
$joins[] = "LEFT JOIN `{$wpdo_table}` `{$alias}` ON `{$prim_table}`.`{$prim_id}` = `{$alias}`.`{$id_col}`";
}
// 組合 where,依 relation 連接
$where_sql = array();
if ( ! empty( $where_clauses ) ) {
$where_sql[] = implode( " {$relation} ", $where_clauses );
}
return array(
'joins' => $joins,
'where' => $where_sql,
);
}
/**
* 產生單一比較子句
*/
private static function build_comparison(
string $alias,
string $col,
string $compare,
$value,
array $field_def
): string {
global $wpdb;
$col_ref = "`{$alias}`.`{$col}`";
switch ( $compare ) {
case 'EXISTS':
return "{$col_ref} IS NOT NULL";
case 'NOT EXISTS':
return "{$col_ref} IS NULL";
case 'BETWEEN':
case 'NOT BETWEEN':
if ( ! is_array( $value ) || count( $value ) !== 2 ) {
return '1=1';
}
$v1 = self::escape_value( $value[0], $field_def );
$v2 = self::escape_value( $value[1], $field_def );
return "{$col_ref} {$compare} {$v1} AND {$v2}";
case 'IN':
case 'NOT IN':
if ( ! is_array( $value ) ) {
$value = array( $value );
}
if ( empty( $value ) ) {
return $compare === 'IN' ? '1=0' : '1=1';
}
$escaped = array_map( fn( $v ) => self::escape_value( $v, $field_def ), $value );
return "{$col_ref} {$compare} (" . implode( ',', $escaped ) . ')';
case 'LIKE':
case 'NOT LIKE':
$like_val = '%' . $wpdb->esc_like( (string) $value ) . '%';
return "{$col_ref} {$compare} '" . esc_sql( $like_val ) . "'";
default:
$escaped = self::escape_value( $value, $field_def );
return "{$col_ref} {$compare} {$escaped}";
}
}
/**
* 型別安全地轉義值
*/
private static function escape_value( $value, array $field_def ): string {
$type = $field_def['type'] ?? 'text';
return match ( $type ) {
'integer', 'boolean' => (string) (int) $value,
'decimal' => (string) (float) $value,
default => "'" . esc_sql( (string) $value ) . "'",
};
}
}
@@ -0,0 +1,341 @@
<?php
/**
* TMDO_Schema_Manager - 動態 DDL 引擎
*
* 功能:
* - 根據欄位定義動態建立/升級扁平化資料表
* - WordPress 型別 → MySQL 型別映射
* - 自動索引策略(B-Tree、全文、唯一)
* - Schema 版本控制(hash 比對)
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Schema_Manager {
/**
* WordPress 邏輯型別 → MySQL 實體型別映射
*/
private static array $type_map = array(
'text' => 'VARCHAR(255)',
'textarea' => 'TEXT',
'integer' => 'BIGINT(20)',
'decimal' => 'DECIMAL(18,6)',
'boolean' => 'TINYINT(1)',
'date' => 'DATE',
'datetime' => 'DATETIME',
'timestamp' => 'TIMESTAMP',
'json' => 'LONGTEXT', // MySQL 5.7.8+ 可用 JSON,為相容性用 LONGTEXT
'enum' => 'VARCHAR(100)', // 在 PHP 層驗證
'binary' => 'LONGBLOB',
);
/**
* 取得完整資料表名稱
*/
public static function get_table_name( string $entity_type, string $group_name ): string {
global $wpdb;
return $wpdb->prefix . TMDO_TABLE_PREFIX . sanitize_key( $entity_type ) . '_' . sanitize_key( $group_name );
}
/**
* 批次處理所有待建表
*/
public static function process_pending_migrations(): void {
$pending = TMDO_Entity_Registry::get_pending_schemas();
foreach ( $pending as $schema ) {
self::create_or_upgrade_table(
$schema['type'],
$schema['group'],
$schema['fields']
);
}
TMDO_Entity_Registry::clear_pending_schemas();
}
/**
* 建立或升級資料表
*/
public static function create_or_upgrade_table(
string $entity_type,
string $group_name,
array $field_definitions
): bool {
global $wpdb;
// Schema 版本比對:若未變動則跳過
$schema_hash = self::calculate_schema_hash( $field_definitions );
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
if ( $stored_hash === $schema_hash ) {
return true;
}
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return false;
}
$table = self::get_table_name( $entity_type, $group_name );
$charset = $wpdb->get_charset_collate();
$id_col = $adapter->get_entity_id_column();
// 建立基礎欄位(每張表都有)
$sql_columns = array(
'`id` BIGINT(20) NOT NULL AUTO_INCREMENT',
"`{$id_col}` BIGINT(20) NOT NULL",
'`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
'`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
);
$sql_indexes = array(
'PRIMARY KEY (`id`)',
"UNIQUE KEY `uk_entity` (`{$id_col}`)",
'KEY `idx_created` (`created_at`)',
);
// 處理動態欄位
foreach ( $field_definitions as $field ) {
$col_name = self::sanitize_column_name( $field['key'] );
$col_type = self::$type_map[ $field['type'] ] ?? 'VARCHAR(255)';
$null_clause = ! empty( $field['required'] ) ? 'NOT NULL' : 'DEFAULT NULL';
$default = self::build_default_clause( $field );
// 組合欄位 DDL
$col_ddl = "`{$col_name}` {$col_type} {$null_clause}";
if ( $default !== '' ) {
$col_ddl .= " {$default}";
}
$sql_columns[] = $col_ddl;
// 索引策略
if ( ! empty( $field['unique'] ) ) {
$sql_indexes[] = "UNIQUE KEY `uk_{$col_name}` (`{$col_name}`)";
} elseif ( ! empty( $field['searchable'] ) ) {
// 不同型別決定索引長度
if ( in_array( $field['type'], array( 'text', 'textarea' ), true ) ) {
// 文字欄位使用前綴索引避免過長
$sql_indexes[] = "KEY `idx_{$col_name}` (`{$col_name}`(100))";
} else {
$sql_indexes[] = "KEY `idx_{$col_name}` (`{$col_name}`)";
}
}
if ( ! empty( $field['fulltext'] ) && in_array( $field['type'], array( 'text', 'textarea' ), true ) ) {
$sql_indexes[] = "FULLTEXT KEY `ft_{$col_name}` (`{$col_name}`)";
}
}
$columns_sql = implode( ",\n ", $sql_columns );
$indexes_sql = implode( ",\n ", $sql_indexes );
$sql = "CREATE TABLE `{$table}` (\n {$columns_sql},\n {$indexes_sql}\n) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// dbDelta 自動處理建表/ALTER TABLE
$dbdelta_result = dbDelta( $sql );
// 記錄 Schema 版本與定義
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
/**
* Action: 表建立/升級完成
*/
do_action( 'wpdo_schema_updated', $entity_type, $group_name, $table, $dbdelta_result );
return true;
}
/**
* 計算 Schema Hash(用於偵測欄位變動)
*/
public static function calculate_schema_hash( array $fields ): string {
// 正規化:僅保留影響 Schema 的屬性
$normalized = array_map(
function ( $f ) {
return array(
'key' => $f['key'] ?? '',
'type' => $f['type'] ?? '',
'required' => ! empty( $f['required'] ),
'default' => $f['default'] ?? null,
'searchable' => ! empty( $f['searchable'] ),
'fulltext' => ! empty( $f['fulltext'] ),
'unique' => ! empty( $f['unique'] ),
);
},
$fields
);
// 依 key 排序以確保 hash 穩定
usort( $normalized, fn( $a, $b ) => strcmp( $a['key'], $b['key'] ) );
$json = wp_json_encode( $normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
return hash( 'sha256', $json );
}
/**
* 欄位名稱清理(防止 SQL 注入)
*/
public static function sanitize_column_name( string $key ): string {
// 移除所有非 alphanumeric/底線
$clean = preg_replace( '/[^a-zA-Z0-9_]/', '', $key );
// 若以數字開頭,前綴 f_
if ( $clean !== '' && preg_match( '/^\d/', $clean ) ) {
$clean = 'f_' . $clean;
}
// MySQL 欄位名長度限制 64 字元
return substr( $clean, 0, 60 );
}
/**
* 建立 DEFAULT 子句
*/
private static function build_default_clause( array $field ): string {
if ( ! isset( $field['default'] ) || $field['default'] === null ) {
return '';
}
$default = $field['default'];
switch ( $field['type'] ) {
case 'integer':
case 'boolean':
return 'DEFAULT ' . (int) $default;
case 'decimal':
return 'DEFAULT ' . (float) $default;
case 'date':
case 'datetime':
case 'timestamp':
if ( strtoupper( (string) $default ) === 'CURRENT_TIMESTAMP' ) {
return 'DEFAULT CURRENT_TIMESTAMP';
}
return "DEFAULT '" . esc_sql( (string) $default ) . "'";
case 'json':
case 'text':
case 'textarea':
case 'enum':
default:
return "DEFAULT '" . esc_sql( (string) $default ) . "'";
}
}
/**
* 儲存 Schema metadata 到 wpdo_registry_meta 表
*/
private static function store_schema_metadata(
string $entity_type,
string $group_name,
string $schema_hash,
array $field_definitions
): void {
global $wpdb;
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
$wpdb->replace(
$table,
array(
'entity_type' => $entity_type,
'group_name' => $group_name,
'schema_hash' => $schema_hash,
'field_definitions' => wp_json_encode( $field_definitions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ),
),
array( '%s', '%s', '%s', '%s' )
);
}
/**
* 取得已儲存的 Schema hash
*/
public static function get_stored_schema_hash( string $entity_type, string $group_name ): string {
global $wpdb;
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
$hash = $wpdb->get_var(
$wpdb->prepare(
"SELECT schema_hash FROM `{$table}` WHERE entity_type = %s AND group_name = %s",
$entity_type,
$group_name
)
);
return $hash ?: '';
}
/**
* 檢查表是否存在
*/
public static function table_exists( string $table_name ): bool {
global $wpdb;
$result = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
return $result === $table_name;
}
/**
* 丟棄表(謹慎使用,僅用於解除安裝)
*/
public static function drop_table( string $entity_type, string $group_name ): bool {
global $wpdb;
$table = self::get_table_name( $entity_type, $group_name );
return (bool) $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
/**
* 取得所有 UAE 建立的表清單
*/
public static function list_all_uae_tables(): array {
global $wpdb;
$prefix = $wpdb->prefix . TMDO_TABLE_PREFIX;
$tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $prefix . '%' ) );
return $tables ?: array();
}
/**
* 取得資料表統計資訊(大小、列數)
*/
public static function get_table_stats( string $table_name ): array {
global $wpdb;
$info = $wpdb->get_row(
$wpdb->prepare(
'SELECT TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s',
DB_NAME,
$table_name
),
ARRAY_A
);
if ( ! $info ) {
return array(
'rows' => 0,
'size_mb' => 0,
'index_mb' => 0,
);
}
return array(
'rows' => (int) $info['TABLE_ROWS'],
'size_mb' => round( $info['DATA_LENGTH'] / 1024 / 1024, 2 ),
'index_mb' => round( $info['INDEX_LENGTH'] / 1024 / 1024, 2 ),
);
}
}
@@ -0,0 +1,407 @@
<?php
/**
* TMDO_Shadow_Diff_Logger - Shadow-read 差異偵測與記錄
*
* 當 bridge 為 shadow_read 模式時,每次讀取都會:
* 1. 從 UAE flat table 取值
* 2. 從 wp_*meta 原生表取值
* 3. 比對
* 4. 若不一致 → 記錄到 wp_wpdo_uni_shadow_diffs 表
*
* 這個表是**持久化**的(不像 AEAV 原本用 transient — 會被 flush 掉),
* 因為驗證期可能跨多日,不能丟失。
*
* 寫入有 rate limit(同 entity+key 在 5 分鐘內只記一筆),防止熱門欄位寫爆。
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Shadow_Diff_Logger {
/** 保留最近 N 筆 diffs(超過就 rolling delete)。防止表爆炸。 */
public const MAX_ROWS = 5000;
/** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */
public const RATELIMIT_WINDOW = 300;
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . 'wpdo_shadow_diffs';
}
/**
* 比對並記錄差異
*
* @param string $entity_type
* @param int $entity_id
* @param string $meta_key
* @param mixed $wpdo_value 從 UAE flat table 取得的值(已型別轉換)
* @param array $field_def field definition(含 type)
*/
public static function compare_and_log(
string $entity_type,
int $entity_id,
string $meta_key,
$wpdo_value,
array $field_def
): void {
// 直接從 native meta 表取(繞過 UAE filter 避免無限迴圈)
$eav_raw = self::get_eav_value_raw( $entity_type, $entity_id, $meta_key );
// 兩邊都無 → 一致
if ( $eav_raw === null && ( $wpdo_value === null || $wpdo_value === '' ) ) {
return;
}
$type = $field_def['type'] ?? 'text';
$eav_val = self::cast_eav( $eav_raw, $type );
// 比對
if ( self::values_equal( $eav_val, $wpdo_value, $type ) ) {
return; // 一致
}
// 不一致 → 檢查 rate limit
if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) ) {
return;
}
// 寫入 diff log
self::record(
$entity_type,
$entity_id,
$meta_key,
self::stringify_for_log( $eav_val ),
self::stringify_for_log( $wpdo_value ),
$type
);
// Trim 舊 rows
self::maybe_trim();
}
/**
* 從 wp_*meta 直接取值(繞過 UAE filter)
*/
private static function get_eav_value_raw( string $entity_type, int $entity_id, string $meta_key ): ?string {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return null;
}
$table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
// 直接 SQL 繞過 get_metadata 系列 filter
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s LIMIT 1",
$entity_id,
$meta_key
)
);
return $value === null ? null : (string) $value;
}
/**
* 把 EAV 原始字串轉為該型別的值(用來比對)
*/
private static function cast_eav( ?string $raw, string $type ) {
if ( $raw === null ) {
return null;
}
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
// Mirrors WP's maybe_unserialize but with allowed_classes=false.
$raw = TMDO_Safe_Unserialize::run( $raw );
switch ( $type ) {
case 'integer':
return is_numeric( $raw ) ? (int) $raw : null;
case 'decimal':
return is_numeric( $raw ) ? (float) $raw : null;
case 'boolean':
return (bool) $raw;
case 'json':
if ( is_array( $raw ) || is_object( $raw ) ) {
return $raw;
}
$decoded = json_decode( (string) $raw, true );
return $decoded !== null ? $decoded : $raw;
default:
return $raw;
}
}
/**
* 兩值是否一致(依型別做合適比對)
*/
private static function values_equal( $a, $b, string $type ): bool {
if ( $a === $b ) {
return true;
}
// JSON / array 比對:用 canonical JSON
if ( $type === 'json' || is_array( $a ) || is_array( $b ) ) {
return wp_json_encode( $a ) === wp_json_encode( $b );
}
// Numeric:比數值
if ( in_array( $type, array( 'integer', 'decimal' ), true ) ) {
return is_numeric( $a ) && is_numeric( $b )
? (float) $a === (float) $b
: $a === $b;
}
// Boolean:寬鬆
if ( $type === 'boolean' ) {
return (bool) $a === (bool) $b;
}
// Default:鬆散字串比較(EAV 字串 vs casted)
return (string) $a === (string) $b;
}
/**
* Rate limit 判斷(同 entity+key 在 300 秒內只記一次)
*/
private static function is_rate_limited( string $entity_type, int $entity_id, string $meta_key ): bool {
$key = 'wpdo_sd_rl_' . md5( "{$entity_type}:{$entity_id}:{$meta_key}" );
if ( get_transient( $key ) !== false ) {
return true;
}
set_transient( $key, 1, self::RATELIMIT_WINDOW );
return false;
}
/**
* 寫入 diff row
*
* v2.1.6 fix: aligned to actual `wpdo_shadow_diffs` schema installed by
* TMDO_Installer. The columns are: ts / entity_type / entity_id / meta_key /
* postmeta_value / zone_value / diff_hash. Logger code previously assumed
* eav_value / wpdo_value / created_at / field_type — schema-vs-code drift
* that silently no-op'd every diff INSERT (wpdb returns 0, no exception).
*
* `field_type` is consumed only as input to diff_hash so the same
* (entity_type, entity_id, meta_key) tuple records distinct rows when the
* field's interpreted type changes (rare; mostly a defence-in-depth bucket).
*/
private static function record(
string $entity_type,
int $entity_id,
string $meta_key,
string $eav_value,
string $wpdo_value,
string $field_type
): void {
global $wpdb;
$table = self::table_name();
$diff_hash = sha1( $entity_type . '|' . $meta_key . '|' . $field_type . '|' . $eav_value . '|' . $wpdo_value );
$wpdb->insert(
$table,
array(
'ts' => current_time( 'mysql', true ),
'entity_type' => $entity_type,
'entity_id' => $entity_id,
'meta_key' => $meta_key,
'postmeta_value' => $eav_value,
'zone_value' => $wpdo_value,
'diff_hash' => $diff_hash,
),
array( '%s', '%s', '%d', '%s', '%s', '%s', '%s' )
);
TMDO_Logger::warning(
'shadow_read_diff',
array(
'entity_type' => $entity_type,
'entity_id' => $entity_id,
'meta_key' => $meta_key,
)
);
}
/**
* 轉為 log 字串
*/
private static function stringify_for_log( $value ): string {
if ( is_scalar( $value ) || $value === null ) {
return (string) $value;
}
return (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
}
/**
* 超過 MAX_ROWS 時 trim 最舊的(chance 1% 才執行,不用每次都跑)
*/
private static function maybe_trim(): void {
if ( wp_rand( 1, 100 ) !== 1 ) {
return;
}
global $wpdb;
$table = self::table_name();
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
if ( $count <= self::MAX_ROWS ) {
return;
}
$to_delete = $count - self::MAX_ROWS;
$wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` ORDER BY id ASC LIMIT %d",
$to_delete
)
);
}
// ─────────────────────────────────────────────────────────
// Read API (for admin UI + CLI)
// ─────────────────────────────────────────────────────────
/**
* 取得最近 N 筆 diffs
*/
public static function recent( int $limit = 100, ?string $entity_type = null ): array {
global $wpdb;
$table = self::table_name();
if ( $entity_type ) {
$sql = $wpdb->prepare(
"SELECT * FROM `{$table}` WHERE entity_type = %s ORDER BY id DESC LIMIT %d",
$entity_type,
$limit
);
} else {
$sql = $wpdb->prepare(
"SELECT * FROM `{$table}` ORDER BY id DESC LIMIT %d",
$limit
);
}
$rows = $wpdb->get_results( $sql, ARRAY_A );
return is_array( $rows ) ? $rows : array();
}
/**
* 統計:按 entity / meta_key 聚合
*/
public static function stats_by_key( int $limit = 20 ): array {
global $wpdb;
$table = self::table_name();
// v2.1.6: column is `ts`, not `created_at` (matches installer schema).
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT entity_type, meta_key, COUNT(*) AS c, MAX(ts) AS last_seen
FROM `{$table}`
GROUP BY entity_type, meta_key
ORDER BY c DESC LIMIT %d",
$limit
),
ARRAY_A
);
return is_array( $rows ) ? $rows : array();
}
/**
* 全部 diff 總數
*/
public static function total_count(): int {
global $wpdb;
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::table_name() . '`' );
}
/**
* 按 entity type 統計
*/
public static function count_by_entity(): array {
global $wpdb;
$rows = $wpdb->get_results(
'SELECT entity_type, COUNT(*) AS c FROM `' . self::table_name() . '` GROUP BY entity_type',
ARRAY_A
);
$result = array();
foreach ( $rows ?: array() as $r ) {
$result[ $r['entity_type'] ] = (int) $r['c'];
}
return $result;
}
/**
* 清空所有 diffs(admin confirmed)
*/
public static function clear_all(): int {
global $wpdb;
$table = self::table_name();
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
$wpdb->query( "TRUNCATE TABLE `{$table}`" );
TMDO_Logger::info(
'shadow_diffs_cleared',
array(
'count' => $count,
'user_id' => get_current_user_id(),
)
);
return $count;
}
/**
* 清除特定 entity 的 diffs
*/
public static function clear_entity( string $entity_type ): int {
global $wpdb;
return (int) $wpdb->query(
$wpdb->prepare(
'DELETE FROM `' . self::table_name() . '` WHERE entity_type = %s',
$entity_type
)
);
}
// ─────────────────────────────────────────────────────────
// Schema
// ─────────────────────────────────────────────────────────
public static function install_table(): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
entity_type VARCHAR(20) NOT NULL,
entity_id BIGINT UNSIGNED NOT NULL,
meta_key VARCHAR(255) NOT NULL,
field_type VARCHAR(20) NOT NULL DEFAULT 'text',
eav_value LONGTEXT NULL,
wpdo_value LONGTEXT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_entity (entity_type, entity_id),
KEY idx_key (entity_type, meta_key(191)),
KEY idx_created (created_at)
) {$charset};";
dbDelta( $sql );
}
public static function drop_table(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS ' . self::table_name() );
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
/**
* TMDO_Type_Caster - 型別轉換器
*
* 負責:
* - PHP → DB 序列化(寫入)
* - DB → PHP 反序列化(讀取)
* - wpdb format string 生成
* - 型別驗證與清理
*
* @package WP_Data_Optimizer
*/
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_Type_Caster {
/**
* PHP → DB 轉換(寫入)
*/
public static function to_db( $value, array $field_def ) {
$type = $field_def['type'] ?? 'text';
// null 值處理
if ( $value === null ) {
if ( ! empty( $field_def['required'] ) ) {
return $field_def['default'] ?? '';
}
return null;
}
switch ( $type ) {
case 'integer':
return (int) $value;
case 'decimal':
return (float) $value;
case 'boolean':
return self::to_bool( $value ) ? 1 : 0;
case 'date':
return self::format_date( $value, 'Y-m-d' );
case 'datetime':
case 'timestamp':
return self::format_date( $value, 'Y-m-d H:i:s' );
case 'json':
if ( is_string( $value ) ) {
// 若已是 JSON 字串則直接儲存(驗證後)
$decoded = json_decode( $value, true );
if ( json_last_error() === JSON_ERROR_NONE ) {
return $value;
}
}
return wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
case 'enum':
$options = $field_def['options'] ?? array();
$str = (string) $value;
return ( ! empty( $options ) && in_array( $str, $options, true ) ) ? $str : ( $field_def['default'] ?? '' );
case 'binary':
return $value;
case 'text':
return (string) $value;
case 'textarea':
return (string) $value;
default:
// 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為)
return maybe_serialize( $value );
}
}
/**
* DB → PHP 轉換(讀取)
*/
public static function from_db( $value, array $field_def ) {
$type = $field_def['type'] ?? 'text';
if ( $value === null ) {
return null;
}
switch ( $type ) {
case 'integer':
return (int) $value;
case 'decimal':
return (float) $value;
case 'boolean':
return (bool) (int) $value;
case 'date':
case 'datetime':
case 'timestamp':
return (string) $value;
case 'json':
$decoded = json_decode( (string) $value, true );
return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value;
case 'enum':
case 'text':
case 'textarea':
return (string) $value;
default:
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
return TMDO_Safe_Unserialize::run( $value );
}
}
/**
* wpdb format 字串(%s / %d / %f
*/
public static function get_wpdb_format( string $type ): string {
return match ( $type ) {
'integer', 'boolean' => '%d',
'decimal' => '%f',
default => '%s',
};
}
// ─────────────────────────────────────────────────────────
// 工具方法
// ─────────────────────────────────────────────────────────
private static function to_bool( $value ): bool {
if ( is_bool( $value ) ) {
return $value;
}
if ( is_numeric( $value ) ) {
return (int) $value !== 0;
}
$str = strtolower( trim( (string) $value ) );
return in_array( $str, array( 'true', 'yes', 'y', '1', 'on' ), true );
}
private static function format_date( $value, string $format ): ?string {
if ( $value === '' || $value === null ) {
return null;
}
// 接受 Unix timestamp
if ( is_numeric( $value ) ) {
return gmdate( $format, (int) $value );
}
$ts = strtotime( (string) $value );
if ( $ts === false ) {
return null;
}
return gmdate( $format, $ts );
}
}