c46814ce83
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
365 lines
11 KiB
PHP
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 ),
|
|
);
|
|
}
|
|
}
|