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