Files
2meet-data-optimizer/includes/engine/class-tmdo-schema-manager.php
T
wpdev e18030b206 fix(2meet-data-optimizer): upgrade existing flat tables from WP-CLI or WP-Cron only
- a changed group definition no longer runs dbDelta on an existing table
  inside a web request (seconds to minutes on a large table, and every
  concurrent request tried it): the group is recorded in tmdo_schema_deferred
  and tmdo_ensure_group_indexes is scheduled, whose request upgrades it
- meanwhile the group stays native: Hook Bus field lookups return no field and
  the router treats it as drifting; afterwards a drift fix
  (tmdo_drift_resync) rebuilds the writes that skipped the flat table
- new tables are still created at once; aeav_only upgrades at once (the flat
  table is the only copy); doctor lists deferred groups
- Verify: SchemaUpgradeDeferralTest, Hook Bus and router tests, integration
  582, unit 701, phpcs, stan; dev36 live cycle in the 2.3 acceptance
- PR: PR-107
2026-09-27 17:07:48 +08:00

701 lines
23 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';
/** Single cron event that adds declared indexes a visitor request had to leave out. */
public const INDEXES_HOOK = 'tmdo_ensure_group_indexes';
/** Option: "entity/group" → time an existing table's upgrade was left to WP-CLI / WP-Cron. */
public const DEFERRED_OPTION = 'tmdo_schema_deferred';
/** 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 );
if ( self::upgrade_waits( $entity_type, $table ) ) {
self::defer_upgrade( "{$entity_type}/{$group_name}" );
return false;
}
$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;
// 記錄 Schema 版本與定義
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
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;
}
// 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.
$lock = self::index_lock_name();
if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', $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 {
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', $lock ) );
}
}
/**
* INDEXES_HOOK handler: add the indexes a visitor request could not.
*
* @return void
*/
public static function ensure_pending_indexes(): void {
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 {
$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 );
}
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade.
}
}
/**
* A deferred upgrade is done: writes made meanwhile skipped the flat table,
* so rebuild it from native (drift fix) a minute later.
*
* @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;
}
$deferred = (array) get_option( self::DEFERRED_OPTION, array() );
unset( $deferred[ "{$entity_type}/{$group_name}" ] );
update_option( self::DEFERRED_OPTION, $deferred, true );
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && ! 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 ) );
}
}
/**
* 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 ),
);
}
}