'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 { // 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 ) { // Upgraded meanwhile, or the definition went back to the stored one. self::end_deferral( $entity_type, $group_name ); return true; } $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); if ( ! $adapter ) { return false; } $table = self::get_table_name( $entity_type, $group_name ); if ( self::upgrade_waits( $entity_type, $table ) ) { self::defer_upgrade( "{$entity_type}/{$group_name}" ); return false; } if ( ! self::table_exists( $table ) ) { if ( ! self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ) ) { return false; } // A new table starts empty while native meta may already hold the // group's values: native until the drift fix has filled it in. if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type ) ) { self::record_deferred( "{$entity_type}/{$group_name}" ); self::end_deferral( $entity_type, $group_name ); } return true; } // An existing table is altered by one process at a time (anonymous // /wp-cron.php requests also pass ddl_allowed()); a process that waited // finds the upgrade done. if ( ! self::lock() ) { return false; } try { if ( self::get_stored_schema_hash( $entity_type, $group_name ) === $schema_hash ) { return true; } return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ); } finally { self::unlock(); } } /** * Create or alter the table (dbDelta) and record its definition. * * @param string $entity_type Entity type. * @param string $group_name Group name. * @param array> $field_definitions Normalized fields. * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. * @param string $table Table name. * @param string $schema_hash Definition hash. * @return bool */ private static function write_table( string $entity_type, string $group_name, array $field_definitions, TMDO_Entity_Adapter_Interface $adapter, string $table, string $schema_hash ): bool { global $wpdb; $existed = self::table_exists( $table ); $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}`)"; } } // A new table gets its declared composite indexes while it is empty. foreach ( TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name ) as $name => $keys ) { $sql_indexes[] = 'KEY `cidx_' . self::sanitize_column_name( (string) $name ) . '` (' . implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) ) . ')'; } $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; // dbDelta reports no failure: record the definition only when every column is there. $columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); $missing = array_diff( array_map( static fn( $field ) => self::sanitize_column_name( (string) $field['key'] ), $field_definitions ), $columns ); if ( $missing ) { unset( self::$table_exists_cache[ $table ] ); TMDO_Logger::warning( 'schema.upgrade_failed', array( 'table' => $table, 'missing' => array_values( $missing ), 'error' => $wpdb->last_error, ) ); return false; } // 記錄 Schema 版本與定義 self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions ); if ( $existed ) { self::end_deferral( $entity_type, $group_name ); } /** * 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. * * On a large table the ALTER takes seconds, so it runs only from WP-CLI or * WP-Cron (ddl_allowed(); other requests schedule INDEXES_HOOK) and in one * process at a time (MySQL GET_LOCK on index_lock_name()). Until every * ALTER succeeds the group stays in pending_indexes() and the table works * without the index. * * @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; } if ( ! self::ddl_allowed() ) { if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) { wp_schedule_single_event( time(), self::INDEXES_HOOK ); } return; } $table = self::get_table_name( $entity_type, $group_name ); if ( ! self::table_exists( $table ) ) { return; } if ( ! self::lock() ) { return; // Another process is adding indexes. } try { // SHOW INDEX (not the option, cached for this request) tells what another process already added. $existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) ); $failed = false; 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 ) ); if ( false === $wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" ) ) { $failed = true; TMDO_Logger::warning( 'schema.index_failed', array( 'table' => $table, 'index' => $index, 'error' => $wpdb->last_error, ) ); } } if ( ! $failed ) { $applied[ $id ] = $signature; update_option( self::INDEXES_OPTION, $applied, true ); } } finally { self::unlock(); } } /** * INDEXES_HOOK handler: the table upgrades and indexes a web request left. * The cron request's init usually did them already; not with * ALTERNATE_WP_CRON, where the event runs inside a visitor's request. * * @return void */ public static function ensure_pending_indexes(): void { foreach ( array_keys( (array) get_option( self::DEFERRED_OPTION, array() ) ) as $id ) { list( $entity_type, $group_name ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' ); $fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); if ( $fields ) { self::create_or_upgrade_table( $entity_type, $group_name, $fields ); } } foreach ( self::pending_indexes() as $id ) { list( $entity_type, $group_name ) = explode( '/', $id, 2 ); self::ensure_group_indexes( $entity_type, $group_name ); } } /** * Whether the group's table upgrade is waiting for WP-CLI / WP-Cron; its * keys then stay native (Hook Bus, query router). * * @param string $entity_type Entity type. * @param string $group_name Group name. * @return bool */ public static function is_deferred( string $entity_type, string $group_name ): bool { $deferred = get_option( self::DEFERRED_OPTION, array() ); return is_array( $deferred ) && isset( $deferred[ "{$entity_type}/{$group_name}" ] ); } /** * Whether an existing table's upgrade waits for WP-CLI / WP-Cron: altering * a large table takes seconds to minutes, as for indexes. Until then the * group reads and writes native meta (is_deferred()). Not in aeav_only, * where the flat table is the only copy: that upgrade runs at once. * * @param string $entity_type Entity type. * @param string $table Flat table name. * @return bool */ public static function upgrade_waits( string $entity_type, string $table ): bool { return self::table_exists( $table ) && ! self::ddl_allowed() && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type ); } /** * Record a deferred table upgrade and have WP-Cron run it. * * @param string $id "entity/group". * @return void */ private static function defer_upgrade( string $id ): void { self::record_deferred( $id ); if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) { wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade. } } /** * Record a group as deferred (native until resynced()). * * @param string $id "entity/group". * @return void */ private static function record_deferred( string $id ): void { $deferred = get_option( self::DEFERRED_OPTION, array() ); $deferred = is_array( $deferred ) ? $deferred : array(); if ( ! isset( $deferred[ $id ] ) ) { $deferred[ $id ] = time(); update_option( self::DEFERRED_OPTION, $deferred, true ); } } /** * A deferred upgrade is done. Writes made meanwhile skipped the flat table, * so the group stays native until a drift fix (tmdo_drift_resync, a minute * later) has rebuilt it from native and calls resynced(). With no flat * writes (disabled), nothing is behind. * * @param string $entity_type Entity type. * @param string $group_name Group name. * @return void */ private static function end_deferral( string $entity_type, string $group_name ): void { if ( ! self::is_deferred( $entity_type, $group_name ) ) { return; } if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) { $deferred = (array) get_option( self::DEFERRED_OPTION, array() ); unset( $deferred[ "{$entity_type}/{$group_name}" ] ); update_option( self::DEFERRED_OPTION, $deferred, true ); return; } if ( ! wp_next_scheduled( TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ) ) { wp_schedule_single_event( time() + MINUTE_IN_SECONDS, TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ); } } /** * A drift fix of the entity succeeded: its deferred groups whose table is * upgraded read and write the flat table again. * * @param string $entity_type Entity type. * @return void */ public static function resynced( string $entity_type ): void { $deferred = get_option( self::DEFERRED_OPTION, array() ); if ( ! is_array( $deferred ) || ! $deferred ) { return; } foreach ( array_keys( $deferred ) as $id ) { list( $type, $group ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' ); if ( $type === $entity_type && self::get_stored_schema_hash( $type, $group ) === self::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) { unset( $deferred[ $id ] ); } } update_option( self::DEFERRED_OPTION, $deferred, true ); } /** * Take the schema lock without waiting. * * add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two * processes both "acquire" an option lock; GET_LOCK is atomic and is * released by MySQL if this process dies. Re-entrant per connection. * * @return bool False when another process holds it. */ private static function lock(): bool { global $wpdb; if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) { // HyperDB / LudicrousDB would send the SELECT GET_LOCK to a replica, // where it locks nothing against the ALTER on the primary. $wpdb->send_reads_to_masters(); } return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', self::index_lock_name() ) ); } /** * Release the schema lock. * * @return void */ private static function unlock(): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', self::index_lock_name() ) ); } /** * Key of the MySQL lock held while indexes are added. Lock names are * server-wide, so SQL hashes it with DATABASE() to keep sites apart. * * @return string */ public static function index_lock_name(): string { global $wpdb; return $wpdb->prefix . self::INDEXES_OPTION; } /** * Whether this request may alter flat tables: WP-CLI or WP-Cron only. * Front-end, REST, admin-ajax, admin-post and even /wp-admin/ requests * (init runs before auth_redirect()) can be a visitor's, who would wait * for the ALTER. Filter: `tmdo_schema_ddl_allowed`. * * @return bool */ public static function ddl_allowed(): bool { $allowed = ( defined( 'WP_CLI' ) && WP_CLI ) || ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ); return (bool) apply_filters( 'tmdo_schema_ddl_allowed', $allowed ); } /** * Registered groups whose declared indexes are not on their table yet. * * @return string[] "entity/group" */ public static function pending_indexes(): array { $applied = get_option( self::INDEXES_OPTION, array() ); $applied = is_array( $applied ) ? $applied : array(); $pending = array(); foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) { foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group_name ) { $declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name ); if ( $declared && ( $applied[ "{$entity_type}/{$group_name}" ] ?? '' ) !== md5( (string) wp_json_encode( $declared ) ) && self::table_exists( self::get_table_name( $entity_type, $group_name ) ) ) { $pending[] = "{$entity_type}/{$group_name}"; } } } return $pending; } /** * 計算 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 ), ); } }