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:
@@ -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: Transient(fallback,資料庫持久化)
|
||||
*
|
||||
* @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 定義)
|
||||
* - 欄位查詢 API(meta_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 ] );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Hook_Bus - 統一 Hook 攔截匯流排
|
||||
*
|
||||
* 所有 WordPress 的 meta 操作皆通過此匯流排:
|
||||
* - {type}_metadata 系列 filter(add/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;
|
||||
}
|
||||
|
||||
// 空 key:WP 要求所有 meta,UAE 不攔截(維持相容性)
|
||||
if ( $meta_key === '' ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||
if ( ! $field_def ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
// ── Mode-aware dispatch ──────────────────────────────
|
||||
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
|
||||
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
|
||||
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff
|
||||
// aeav_only : 讀取走 UAE flat,不讀 EAV
|
||||
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
|
||||
return $check; // disabled / dual_write
|
||||
}
|
||||
|
||||
$group = $field_def['group'];
|
||||
$row = self::get_or_load_row( $type, (int) $object_id, $group );
|
||||
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||
$has_value = is_array( $row ) && array_key_exists( $col, $row );
|
||||
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
|
||||
|
||||
// Shadow-read:與 EAV 比對,記錄差異
|
||||
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
|
||||
try {
|
||||
TMDO_Shadow_Diff_Logger::compare_and_log(
|
||||
$type,
|
||||
(int) $object_id,
|
||||
$meta_key,
|
||||
$value,
|
||||
$field_def
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
// 比對失敗不該影響讀取
|
||||
TMDO_Logger::error(
|
||||
'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 );
|
||||
}
|
||||
}
|
||||
@@ -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_Query(post 實體)
|
||||
*
|
||||
* @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() );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user