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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
@@ -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();
}
}