Files
2meet-data-optimizer/includes/engine/class-tmdo-entity-migration-engine.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

802 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_Entity_Migration_Engine - 遷移引擎
*
* 負責將原生 wp_*meta 表中的 EAV 資料搬移至 UAE 扁平化表
*
* 特性:
* - Cursor-based 分頁(避免 OFFSET 效能問題)
* - 斷點續傳(記錄 last_id
* - 批次處理(可配置 batch size
* - Transaction 安全
* - 統計報告
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
defined( 'ABSPATH' ) || exit;
final class TMDO_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();
}
}