65c75dfc8d
- 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
522 lines
16 KiB
PHP
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 ),
|
|
);
|
|
}
|
|
}
|