Files
2meet-data-optimizer/includes/engine/class-tmdo-schema-manager.php
T
wpdev 65c75dfc8d feat(2meet-data-optimizer): support composite indexes on entity groups
- register_group() accepts $args['indexes'] (name => ordered keys); indexes on
  unknown keys are dropped. Kept outside the field definition, so schema
  hashes are unchanged.
- Schema_Manager::ensure_group_indexes() adds missing cidx_* indexes to the
  existing flat table in place; the applied definition is remembered in
  tmdo_entity_indexes so later requests skip SHOW INDEX.
- Bench (1M listings, scenario 2): Entity p50 337 ms -> 278 ms with
  (hp_verified, hp_price), on par with Hot (269 ms).
- Verify: core unit 656 / integration 459 green, phpcs clean
- PR: PR-027
2026-09-26 01:05:23 +08:00

522 lines
16 KiB
PHP

<?php
/**
* TMDO_Schema_Manager - 動態 DDL 引擎
*
* 功能:
* - 根據欄位定義動態建立/升級扁平化資料表
* - WordPress 型別 → MySQL 型別映射
* - 自動索引策略(B-Tree、全文、唯一)
* - Schema 版本控制(hash 比對)
*
* @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_Schema_Manager {
/** Option: "entity/group" → md5 of the composite index definition already applied. */
public const INDEXES_OPTION = 'tmdo_entity_indexes';
/** 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']
);
self::ensure_group_indexes( $schema['type'], $schema['group'] );
}
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;
}
/**
* Add the group's declared composite indexes that its table lacks.
*
* Indexes are not part of the schema hash, so an existing table (which
* may already hold aeav_only data) gets them in place — no rebuild. The
* applied definition is remembered per group (one autoloaded option), so
* requests only run SHOW INDEX / ALTER when the declaration changes.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return void
*/
public static function ensure_group_indexes( string $entity_type, string $group_name ): void {
global $wpdb;
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
if ( empty( $declared ) ) {
return;
}
$id = "{$entity_type}/{$group_name}";
$signature = md5( (string) wp_json_encode( $declared ) );
$applied = get_option( self::INDEXES_OPTION, array() );
$applied = is_array( $applied ) ? $applied : array();
if ( ( $applied[ $id ] ?? '' ) === $signature ) {
return;
}
$table = self::get_table_name( $entity_type, $group_name );
if ( ! self::table_exists( $table ) ) {
return;
}
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
foreach ( $declared as $name => $keys ) {
$index = 'cidx_' . self::sanitize_column_name( $name );
if ( in_array( $index, $existing, true ) ) {
continue;
}
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
$wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" );
}
$applied[ $id ] = $signature;
update_option( self::INDEXES_OPTION, $applied, 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' )
);
}
/**
* 還原孤兒群組:扁平表還在、定義存在 wpdo_registry_meta,但已沒有外掛註冊。
*
* 在所有註冊都完成後(init:1 晚綁定)呼叫。只處理 aeav_only 的實體:其他
* 模式仍會寫原生 meta,孤兒 key 從原生讀取即正確;全部非 aeav_only 時不查資料庫。
* 原生 meta 已有資料的 key(孤兒期間寫入,比扁平表新)留在原生,不還原。
* 還原只影響讀寫路由,不會建立或修改任何資料表。
*
* @return int 還原的群組數
*/
public static function restore_orphaned_groups(): int {
global $wpdb;
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
return 0;
}
$types = array_values(
array_filter(
TMDO_Entity_Registry::VALID_ENTITY_TYPES,
static fn( $type ) => TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $type )
)
);
if ( empty( $types ) ) {
return 0;
}
$meta_table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT entity_type, group_name, field_definitions FROM `{$meta_table}` WHERE entity_type IN ({$placeholders})", ...$types ),
ARRAY_A
);
$orphans = array();
foreach ( (array) $rows as $row ) {
if ( ! TMDO_Entity_Registry::get_group_fields( $row['entity_type'], $row['group_name'] ) ) {
$orphans[] = $row;
}
}
if ( empty( $orphans ) ) {
return 0;
}
// One SHOW TABLES for all candidates instead of one per orphan.
$existing = (array) $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . TMDO_TABLE_PREFIX ) . '%' ) );
// "entity:key" → native meta has rows. Stable while the key is restored
// (aeav_only writes go to the flat table only), so a day-long cache is safe.
$native_cache = get_transient( 'tmdo_orphan_native_keys' );
$native_cache = is_array( $native_cache ) ? $native_cache : array();
$cache_dirty = false;
$restored = 0;
foreach ( $orphans as $row ) {
if ( ! in_array( self::get_table_name( $row['entity_type'], $row['group_name'] ), $existing, true ) ) {
continue;
}
$fields = json_decode( (string) $row['field_definitions'], true );
if ( ! is_array( $fields ) ) {
continue;
}
$native_keys = array();
foreach ( $fields as $field ) {
$key = (string) ( $field['key'] ?? '' );
if ( '' === $key ) {
continue;
}
$cache_key = $row['entity_type'] . ':' . $key;
if ( ! array_key_exists( $cache_key, $native_cache ) ) {
$native_cache[ $cache_key ] = self::native_meta_has_key( $row['entity_type'], $key );
$cache_dirty = true;
}
if ( $native_cache[ $cache_key ] ) {
$native_keys[] = $key;
}
}
if ( TMDO_Entity_Registry::restore_group( $row['entity_type'], $row['group_name'], $fields, $native_keys ) ) {
++$restored;
}
}
if ( $cache_dirty ) {
set_transient( 'tmdo_orphan_native_keys', $native_cache, DAY_IN_SECONDS );
}
return $restored;
}
/**
* Whether the entity's native meta table holds any row for this key.
*/
private static function native_meta_has_key( string $entity_type, string $meta_key ): bool {
global $wpdb;
$tables = array(
'post' => $wpdb->postmeta,
'user' => $wpdb->usermeta,
'term' => $wpdb->termmeta,
'comment' => $wpdb->commentmeta,
);
if ( empty( $tables[ $entity_type ] ) ) {
return false;
}
return (bool) $wpdb->get_var(
$wpdb->prepare( "SELECT 1 FROM `{$tables[ $entity_type ]}` WHERE meta_key = %s LIMIT 1", $meta_key )
);
}
/**
* 取得已儲存的 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 ),
);
}
}