Files
2meet-data-optimizer/includes/engine/class-tmdo-schema-manager.php
T
wpdev c46814ce83 refactor(migration): 回填 Migration Phase Strategy 體系(PR-D)
A v3.0.1 把 orchestrator 的 11 個 phase 抽成可注入的 Phase 物件,B 仍是
1107 行單體、以 'phase_' . $current 字串魔法分派、completed 甚至 inline
在 tick() 裡。本 commit 對齊:

新增 13 檔
- includes/migration/interface-migration-phase.php
- includes/migration/class-tmdo-migration-phase-base.php(log/get_managed_keys/
  execute_bulk_pivot/values_loose_equal 等共用 helper)
- includes/migration/phases/ 11 個 phase 類別

orchestrator 1107 → 640 行
- tick() 改 make_phase() 工廠 + $phase->execute($job)
- 移除 final、self::ENTITY_TYPE → static::(A v3.3.0 late static binding)
- 保留 B 原有的 '✓ %s (%.2fs)' 耗時 log(改為 tick 自行量測,A 版已簡化掉)
- 公開介面(preflight/start/tick/get_status/cancel/resume/needs_attention/
  cron_tick)經 diff 確認與 A 完全一致,呼叫端零影響

連帶
- Schema_Manager 補 table_exists 的 request-scoped cache 與
  flush_table_exists_cache()(A v3.1.6 + v3.4.6),Phase 測試需要它
- back-compat 補 12 個 Phase 類別的 WPDO_ alias
- 移植 MigrationPhaseTest + MigrationPhaseRemainingTest(527 行)

測試隔離差異(B 的 unit bootstrap 會載入 Member_Fields / Post_Fields,
A 的不會):MigrationPhaseRemainingTest 的 setUp 需額外清空 Entity_Registry
與 wpdo_register_entity_fields listener,否則 install_schema 會真的走進
dbDelta。MigrationPhaseTest 的 interface 斷言改用 TMDO_ 正式名稱
(PHP 無法 class_alias 介面,且該契約是核心內部擴充點)。

unit 451 / integration 398 GREEN

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

365 lines
11 KiB
PHP

<?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 {
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
private static array $table_exists_cache = array();
/**
* 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 );
// Bust the request-scoped table_exists cache so subsequent calls see the new table.
self::$table_exists_cache[ $table ] = true;
// 記錄 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;
if ( isset( self::$table_exists_cache[ $table_name ] ) ) {
return self::$table_exists_cache[ $table_name ];
}
$result = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
self::$table_exists_cache[ $table_name ] = $result === $table_name;
return self::$table_exists_cache[ $table_name ];
}
/**
* Clear the request-scoped table-exists cache.
*
* Mirrors TMDO_Routing_Predicate::flush_cache(): call in test setUp to isolate
* cases. In production the cache is correct for a request lifetime; tests that
* create then drop flat tables across classes must reset it so a stale `true`
* does not survive after a table is dropped.
*/
public static function flush_table_exists_cache(): void {
self::$table_exists_cache = array();
}
/**
* 丟棄表(謹慎使用,僅用於解除安裝)
*/
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 ),
);
}
}