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,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() );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user