'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 ), ); } }