Files
2meet-data-optimizer/includes/class-tmdo-installer.php
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

1397 lines
48 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Database table installer for WP Data Optimizer.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Installs and upgrades all WPDO custom tables.
*
* Three-layer strategy (inherited from HPCT):
* Layer 1 dbDelta() with MySQL+SQLite-compatible DDL
* Layer 2 MySQL-only ALTER TABLE for composite UNIQUE indexes
* Layer 3 SQLite info_schema patch
*
* WPDO owns:
* - System tables: wpdo_migrations, wpdo_errors, wpdo_benchmarks
* - Zone B table: wpdo_warm
* - Zone D table: wpdo_archive
* - Zone A tables: wpdo_hot_{post_type} (created dynamically by Schema Registry)
* - Zone C tables: wpdo_cold_{post_type} (created dynamically by Schema Registry)
*
* HPCT tables (hpct_*) are NOT created by this installer.
* They are managed by hp-custom-tables and reused by WPDO after import.
*/
class TMDO_Installer {
/**
* Incremented each time DDL changes.
*
* V1.0.0 (PR-0 base) holds the original 4-zone schema; v2.0.0 adds the
* wpdo_audit / wpdo_shadow_diffs / wpdo_site_metrics / wpdo_uni_options
* tables installed via install_v2_tables() during V2 upgrade.
*/
private const SCHEMA_VERSION = '2.1.0';
/** Allowed SQL base types for hot-zone column definitions. */
private const ALLOWED_COL_BASE_TYPES = array(
'bigint',
'int',
'tinyint',
'smallint',
'mediumint',
'decimal',
'float',
'double',
'varchar',
'char',
'text',
'longtext',
'mediumtext',
'datetime',
'date',
'timestamp',
'json',
);
/**
* Assert that a column type string starts with an allowed SQL base type.
*
* Prevents rogue partner plugins from injecting arbitrary DDL via Schema Registry.
*
* @param string $col_type Full column definition, e.g. "DECIMAL(10,2) NOT NULL DEFAULT '0'".
* @param string $col_name Column name (for error context).
* @throws \InvalidArgumentException When the base type is not in the allowlist.
*/
private static function validate_col_type( string $col_type, string $col_name ): void {
$base = strtolower( strtok( trim( $col_type ), " \t(" ) );
if ( ! in_array( $base, self::ALLOWED_COL_BASE_TYPES, true ) ) {
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- internal exception, never rendered to HTML.
throw new \InvalidArgumentException(
"TMDO_Installer: disallowed column type '{$base}' for column '{$col_name}'. " .
'Allowed: ' . implode( ', ', self::ALLOWED_COL_BASE_TYPES )
);
// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
}
// ── Public API ────────────────────────────────────────────────────────
/**
* Called on plugin activation.
*
* @param bool $network_wide True when activated network-wide in Multisite.
*/
public static function activate( bool $network_wide = false ): void {
if ( $network_wide && is_multisite() ) {
self::install_network();
} else {
self::install();
}
}
/**
* Install tables on every site in the network.
*/
private static function install_network(): void {
$offset = 0;
$batch = 100;
do {
$site_ids = get_sites(
array(
'number' => $batch,
'offset' => $offset,
'fields' => 'ids',
)
);
foreach ( $site_ids as $blog_id ) {
switch_to_blog( (int) $blog_id );
try {
self::install();
} finally {
// v2.14.1: ensure blog context restored even if install() throws.
restore_current_blog();
}
}
$offset += $batch;
} while ( count( $site_ids ) === $batch ); // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- count() used in do-while condition, loop body does not modify $site_ids, so caching is unnecessary.
}
/**
* Auto-install tables when a new site is created in Multisite.
*
* @param \WP_Site $site The newly created site.
*/
public static function on_new_site( \WP_Site $site ): void {
if ( ! is_plugin_active_for_network( plugin_basename( TMDO_FILE ) ) ) {
return;
}
switch_to_blog( (int) $site->blog_id );
try {
self::install();
} finally {
// v2.14.1: ensure blog context restored even if install() throws.
restore_current_blog();
}
}
/**
* Drop WPDO tables when a Multisite site is being deleted (v2.14.0).
*
* Fires on `wp_uninitialize_site` (the recommended hook for plugins to
* clean up site-scoped data; runs BEFORE WordPress drops the core
* wp_N_* tables). Without this hook, deleting a site leaves up to ~36
* orphan `wp_N_wpdo_*` tables permanently consuming DB space.
*
* Only runs when the plugin is network-active (per-site activations
* are scoped to that single site, no cross-site cleanup needed).
*
* @param \WP_Site|int $site Site or site ID being deleted.
* @return void
* @since 2.14.0
*/
public static function on_site_delete( $site ): void {
if ( ! is_multisite() ) {
return;
}
if ( function_exists( 'is_plugin_active_for_network' )
&& ! is_plugin_active_for_network( plugin_basename( TMDO_FILE ) )
) {
return;
}
$blog_id = $site instanceof \WP_Site ? (int) $site->blog_id : (int) $site;
if ( $blog_id < 1 ) {
return;
}
switch_to_blog( $blog_id );
try {
$counts = self::drop_all_tables_for_current_blog();
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'multisite_site_delete_cleanup',
array(
'blog_id' => $blog_id,
'counts' => $counts,
)
);
}
} finally {
restore_current_blog();
}
}
/**
* Install tables for the current blog.
*
* Idempotent. Safely re-runs on every activation / version bump.
* v2.0.0: also installs the entity / audit / shadow_diffs / site_metrics
* tables via install_v2_tables() — kept here so plain `wp plugin activate`
* gets a complete v2 schema without needing the explicit V2_Upgrader run.
*/
public static function install(): void {
self::run_dbdelta();
if ( TMDO_IS_MYSQL ) {
self::run_mysql_indexes();
}
if ( TMDO_IS_SQLITE ) {
TMDO_SQLite_Compat::patch_all();
}
// v2.0.0 entity + audit + shadow_diffs + site_metrics + uni_options.
// Idempotent — only creates tables that don't already exist.
self::install_v2_tables();
update_option( 'wpdo_db_version', self::SCHEMA_VERSION );
// Register wpdo_features with autoload=no so it doesn't inflate every page load.
if ( false === get_option( 'wpdo_features' ) ) {
add_option( 'wpdo_features', array(), '', 'no' );
}
// v2.5.4: one-time migration — enable Hook Bus + activate user/term/comment bridge.
self::maybe_autoactivate_entity_bridge();
// v2.15.0: one-time crypto migration — re-encrypt v1 CBC ciphertext as
// v2 GCM. Idempotent best-effort: skipped if no v1 ciphertext present
// or if the migration flag is already set.
self::maybe_migrate_crypto_v1_to_v2();
}
/**
* One-time migration: enable Hook Bus and set user/term/comment to dual_write.
*
* Runs once per site (guarded by wpdo_entity_bridge_autoactivated_v2 flag).
* Safe: dual_write still writes native EAV — zero data-loss risk.
*
* @return void
*/
private static function maybe_autoactivate_entity_bridge(): void {
if ( get_option( 'wpdo_entity_bridge_autoactivated_v2' ) ) {
return;
}
// Enable Hook Bus.
update_option( 'wpdo_hook_bus_enabled', '1', false );
// Upgrade user/term/comment from disabled → dual_write.
$modes = get_option( 'wpdo_bridge_modes', array() );
if ( ! is_array( $modes ) ) {
$modes = array();
}
foreach ( array( 'user', 'term', 'comment' ) as $type ) {
if ( ! isset( $modes[ $type ] ) || 'disabled' === $modes[ $type ] ) {
$modes[ $type ] = 'dual_write';
}
}
// post entity is intentionally left alone — it uses the legacy Feature_Flags FSM.
update_option( 'wpdo_bridge_modes', $modes, false );
update_option( 'wpdo_entity_bridge_autoactivated_v2', '1', false );
}
/**
* One-time best-effort crypto migration v1 (CBC) → v2 (GCM) (v2.15.0).
*
* Idempotent: guarded by the `wpdo_crypto_migrated_v2` flag option. Re-runs
* are no-ops. Errors are logged but do not block plugin activation — v1
* blobs remain readable so notifications continue to work.
*
* @return void
*/
private static function maybe_migrate_crypto_v1_to_v2(): void {
if ( get_option( 'wpdo_crypto_migrated_v2' ) ) {
return;
}
if ( ! class_exists( 'TMDO_Crypto' ) ) {
return;
}
$counts = TMDO_Crypto::migrate_v1_to_v2( 'wpdo_' );
if ( $counts['migrated'] > 0 || $counts['failed'] > 0 ) {
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info( 'crypto_migrate_v1_to_v2', $counts );
}
}
// Mark as migrated even if 0 v1 ciphertexts were found, so we don't
// re-scan wp_options on every activation. The flag itself uses the
// option name pattern but isn't an encrypted secret.
update_option( 'wpdo_crypto_migrated_v2', '1', false );
}
/**
* Called on plugins_loaded to upgrade when SCHEMA_VERSION changes.
*/
public static function maybe_upgrade(): void {
if ( get_option( 'wpdo_db_version' ) !== self::SCHEMA_VERSION ) {
self::install();
}
}
// ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ─────────────────
// These tables are installed only when v2.0.0 upgrade runs (PR-7).
// install_v2_tables() is idempotent; safe to call multiple times.
/**
* Install v2.0.0 tables — entity tables (user/term/comment), audit log,
* shadow_diffs, site_metrics, options manager backing table.
*
* Idempotent. Called from TMDO_V2_Upgrader::upgrade_to_v2() in PR-7.
*
* @internal Public so PR-7 upgrader and integration tests can invoke directly.
* @return void
*/
public static function install_v2_tables(): void {
global $wpdb;
// Allow integration tests to provide a stub dbDelta() without WP being bootstrapped.
if ( ! function_exists( 'dbDelta' ) ) {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
}
$charset = $wpdb->get_charset_collate();
$p = $wpdb->prefix;
$sqls = array();
// ── wpdo_audit (structured op-aware audit log) ────────────────────
$sqls[] = "CREATE TABLE {$p}wpdo_audit (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
ts datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
entity_type varchar(20) NOT NULL DEFAULT '',
entity_id bigint(20) unsigned NOT NULL DEFAULT 0,
group_name varchar(50) NOT NULL DEFAULT '',
meta_key varchar(255) NOT NULL DEFAULT '',
action varchar(20) NOT NULL DEFAULT '',
op varchar(20) NOT NULL DEFAULT '',
value_before longtext,
value_after longtext,
source varchar(20) NOT NULL DEFAULT '',
trace_id varchar(36) NOT NULL DEFAULT '',
PRIMARY KEY (id),
KEY idx_entity (entity_type, entity_id),
KEY idx_meta_key (meta_key(191)),
KEY idx_ts (ts),
KEY idx_trace (trace_id)
) {$charset};";
// ── wpdo_shadow_diffs (verify-stage divergence log) ───────────────
$sqls[] = "CREATE TABLE {$p}wpdo_shadow_diffs (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
ts datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
entity_type varchar(20) NOT NULL DEFAULT '',
entity_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) NOT NULL DEFAULT '',
postmeta_value longtext,
zone_value longtext,
diff_hash varchar(40) NOT NULL DEFAULT '',
PRIMARY KEY (id),
KEY idx_entity (entity_type, entity_id),
KEY idx_meta_key (meta_key(191)),
KEY idx_diff_hash (diff_hash),
KEY idx_ts (ts)
) {$charset};";
// ── wpdo_site_metrics (Part C.3 site-wide EAV health) ─────────────
$sqls[] = "CREATE TABLE {$p}wpdo_site_metrics (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
collected_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
metric_key varchar(100) NOT NULL DEFAULT '',
metric_value bigint(20) NOT NULL DEFAULT 0,
context longtext,
PRIMARY KEY (id),
KEY idx_metric_key (metric_key),
KEY idx_collected_at (collected_at)
) {$charset};";
// ── wpdo_uni_options (autoload-optimized options) ─────────────────
$sqls[] = "CREATE TABLE {$p}wpdo_uni_options (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
option_name varchar(191) NOT NULL DEFAULT '',
option_value longtext NOT NULL,
autoload varchar(20) NOT NULL DEFAULT 'no',
provider varchar(50) NOT NULL DEFAULT '',
updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
UNIQUE KEY ui_option_name (option_name),
KEY idx_provider (provider)
) {$charset};";
// ── wpdo_registry_meta (v2.5.1 fix — engine schema_manager metadata catalog) ─
// Stores schema hash + field_definitions per (entity_type, group_name).
// Previously referenced by `TMDO_Schema_Manager::store_schema_metadata` /
// `get_stored_schema_hash` via `$wpdb->replace()` but never created → produced
// continuous WordPress database errors on every wp-cli init. Schema derived
// from the columns those callers write.
$sqls[] = "CREATE TABLE {$p}wpdo_registry_meta (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
entity_type varchar(20) NOT NULL DEFAULT '',
group_name varchar(40) NOT NULL DEFAULT '',
schema_hash varchar(64) NOT NULL DEFAULT '',
field_definitions longtext,
updated_at datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (id),
UNIQUE KEY ui_entity_group (entity_type, group_name),
KEY idx_schema_hash (schema_hash)
) {$charset};";
// ── wpdo_snapshots (v2.2.0 M1 — backup/restore catalog with hybrid storage) ─
// Catalog rows for every backup snapshot. Small payloads (≤5MB) live in
// `inline_blob`; larger snapshots are stored as gzipped SQL dumps under
// wp-content/uploads/wpdo-backups/<snapshot_id>.sql.gz with sha256 verify.
// `trigger` records why the snapshot was created (manual / pre_fsm_transition /
// pre_v2_upgrade / scheduled / pre_uninstall). `scope` JSON declares which
// entities/modules were dumped. `expires_at` powers the daily prune cron.
$sqls[] = "CREATE TABLE {$p}wpdo_snapshots (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
snapshot_id varchar(64) NOT NULL DEFAULT '',
trigger_type varchar(40) NOT NULL DEFAULT 'manual',
scope longtext,
size_bytes bigint(20) unsigned NOT NULL DEFAULT 0,
row_count bigint(20) unsigned NOT NULL DEFAULT 0,
storage varchar(20) NOT NULL DEFAULT 'file',
file_path varchar(500) DEFAULT NULL,
file_sha256 varchar(64) DEFAULT NULL,
inline_blob longblob,
fsm_states longtext,
notes varchar(500) DEFAULT NULL,
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
expires_at datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY ui_snapshot_id (snapshot_id),
KEY idx_trigger_created (trigger_type, created_at),
KEY idx_expires (expires_at)
) {$charset};";
// ── wpdo_wc_commissions (v2.1.0 WC integration — vendor commission) ─
// Replaces legacy `hpct_wc_orders` from HPCT era. HPOS-aware via
// wc_get_order() abstraction in TMDO_WC_Orders_Interceptor.
$sqls[] = "CREATE TABLE {$p}wpdo_wc_commissions (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
wc_order_id bigint(20) unsigned NOT NULL DEFAULT 0,
vendor_id bigint(20) unsigned NOT NULL DEFAULT 0,
listing_id bigint(20) unsigned NOT NULL DEFAULT 0,
subtotal decimal(15,4) NOT NULL DEFAULT 0,
commission decimal(15,4) NOT NULL DEFAULT 0,
vendor_payout decimal(15,4) NOT NULL DEFAULT 0,
commission_rate decimal(5,2) NOT NULL DEFAULT 0,
status varchar(40) NOT NULL DEFAULT 'pending',
hpos_enabled tinyint(1) NOT NULL DEFAULT 0,
payout_at datetime DEFAULT NULL,
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
UNIQUE KEY ui_order_vendor (wc_order_id, vendor_id),
KEY idx_vendor (vendor_id),
KEY idx_status (status),
KEY idx_payout_at (payout_at)
) {$charset};";
// ── wpdo_migration_status (entity migration engine checkpoint) ────────
// Bug fix: TMDO_Entity_Migration_Engine::migrate_group() reads/writes this
// table at every batch. Missing in original schema → first backfill fails.
$sqls[] = "CREATE TABLE {$p}wpdo_migration_status (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
entity_type varchar(20) NOT NULL DEFAULT '',
group_name varchar(40) NOT NULL DEFAULT '',
last_id bigint(20) unsigned NOT NULL DEFAULT 0,
total_migrated bigint(20) unsigned NOT NULL DEFAULT 0,
status varchar(20) NOT NULL DEFAULT 'idle',
started_at datetime NULL DEFAULT NULL,
completed_at datetime NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY ui_entity_group (entity_type, group_name)
) {$charset};";
// ── wpdo_user_points_ledger (append-only points journal) ──────────────
// One row per transaction. idx_user_created is a covering index —
// WHERE user_id=? ORDER BY created_at DESC LIMIT N never touches data pages.
$sqls[] = "CREATE TABLE {$p}wpdo_user_points_ledger (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
delta int(11) NOT NULL DEFAULT 0,
balance_after bigint(20) NOT NULL DEFAULT 0,
reason varchar(60) NOT NULL DEFAULT '',
ref_id bigint(20) DEFAULT NULL,
ref_type varchar(30) DEFAULT NULL,
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY idx_user_created (user_id, created_at),
KEY idx_user_reason (user_id, reason),
KEY idx_created (created_at)
) {$charset};";
// ── v2.12.4 Phase 4: Term + Comment misc bucket (catch-all flat) ───────
// Last-resort storage for unregistered term/comment meta keys, so
// wp_termmeta / wp_commentmeta can become DROPpable in v3.0.0.
// Composite PK (entity_id, meta_key) means each (entity, key) pair
// has exactly one canonical row — UPSERT semantics on write.
$sqls[] = "CREATE TABLE {$p}wpdo_term_misc (
term_id bigint(20) unsigned NOT NULL,
meta_key varchar(191) NOT NULL,
meta_value longtext,
updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (term_id, meta_key),
KEY meta_key (meta_key)
) {$charset};";
$sqls[] = "CREATE TABLE {$p}wpdo_comment_misc (
comment_id bigint(20) unsigned NOT NULL,
meta_key varchar(191) NOT NULL,
meta_value longtext,
updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (comment_id, meta_key),
KEY meta_key (meta_key)
) {$charset};";
foreach ( $sqls as $sql ) {
dbDelta( $sql );
}
if ( TMDO_IS_SQLITE ) {
TMDO_SQLite_Compat::patch_table( $p . 'wpdo_audit', self::v2_audit_sqlite_cols() );
TMDO_SQLite_Compat::patch_table( $p . 'wpdo_shadow_diffs', self::v2_shadow_diffs_sqlite_cols() );
}
// Composite indexes for member flat tables — MySQL-only, idempotent.
if ( TMDO_IS_MYSQL ) {
self::install_member_indexes();
}
}
/**
* Add composite indexes to wp_wpdo_user_membership after Schema Manager
* creates the flat table from Entity Registry field definitions.
*
* Three indexes enable the key 10M-scale query patterns:
* idx_level_expires → WHERE level='gold' AND expires_at < NOW()
* idx_expires_level → ORDER BY expires_at (scan all expiring this month)
* idx_points_bal → ORDER BY points_balance DESC (leaderboard)
*
* Safe to call multiple times — skips existing indexes.
*
* @return void
*/
private static function install_member_indexes(): void {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_user_membership';
// Skip if Schema Manager hasn't created the table yet.
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
return;
}
$desired = array(
'idx_level_expires' => 'ADD INDEX `idx_level_expires` (membership_level, membership_expires_at)',
'idx_expires_level' => 'ADD INDEX `idx_expires_level` (membership_expires_at, membership_level)',
'idx_points_bal' => 'ADD INDEX `idx_points_bal` (points_balance)',
);
foreach ( $desired as $idx_name => $add_sql ) {
$exists = (int) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s',
$table,
$idx_name
)
);
if ( ! $exists ) {
$wpdb->query( "ALTER TABLE `{$table}` {$add_sql}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- table/index names are safe string literals with no user input
}
}
}
/**
* SQLite column metadata for wpdo_audit (used by SQLite_Compat patcher).
*
* @return array<int, array{name:string, type:string, nullable:bool, default:?string}>
*/
private static function v2_audit_sqlite_cols(): array {
return array(
array(
'name' => 'id',
'type' => 'bigint',
'nullable' => false,
'default' => null,
),
array(
'name' => 'ts',
'type' => 'datetime',
'nullable' => false,
'default' => '0000-00-00 00:00:00',
),
array(
'name' => 'entity_type',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'entity_id',
'type' => 'bigint',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'op',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'trace_id',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
);
}
/**
* SQLite column metadata for wpdo_shadow_diffs.
*
* @return array<int, array{name:string, type:string, nullable:bool, default:?string}>
*/
private static function v2_shadow_diffs_sqlite_cols(): array {
return array(
array(
'name' => 'id',
'type' => 'bigint',
'nullable' => false,
'default' => null,
),
array(
'name' => 'entity_type',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
array(
'name' => 'entity_id',
'type' => 'bigint',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'diff_hash',
'type' => 'varchar',
'nullable' => false,
'default' => '',
),
);
}
/**
* Verify v2 tables exist. Used by upgrader pre-flight + tests.
*
* @return array<string, bool> Table name => exists.
*/
public static function v2_tables_status(): array {
global $wpdb;
$p = $wpdb->prefix;
$tables = array(
$p . 'wpdo_audit',
$p . 'wpdo_shadow_diffs',
$p . 'wpdo_site_metrics',
$p . 'wpdo_uni_options',
);
$status = array();
foreach ( $tables as $table ) {
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
$status[ $table ] = $exists;
}
return $status;
}
/**
* Called on plugin deactivation. Non-destructive.
*/
public static function deactivate(): void {
wp_clear_scheduled_hook( 'wpdo_warm_cleanup' );
wp_clear_scheduled_hook( 'wpdo_archive_sweep' );
// v2.14.0: clear all plugin cron hooks (was incomplete pre-v2.14.0).
wp_clear_scheduled_hook( 'wpdo_errors_gc' );
wp_clear_scheduled_hook( 'wpdo_flush_views' );
wp_clear_scheduled_hook( 'wpdo_remove_uae_plugin_dir' );
wp_clear_scheduled_hook( 'wpdo_daily_health_check' );
wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' );
wp_clear_scheduled_hook( 'wpdo_fsm_automator_run' );
wp_clear_scheduled_hook( 'wpdo_collect_site_metrics' );
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_comment_stress_test_batch' );
}
// ── v2.14.0: Multisite-aware site cleanup ─────────────────────────────────
/**
* Drop all WPDO tables and clear options + cron for the *current* blog.
*
* Idempotent. Used by:
* - `uninstall.php` (single-site delete-and-uninstall path)
* - `on_site_delete()` / `on_uninitialize_site()` (multisite per-site cleanup)
* - `uninstall.php` network branch via `switch_to_blog` loop
*
* Does NOT take pre-uninstall snapshot — that's the caller's responsibility
* (snapshot only makes sense at uninstall, not at site-deletion).
*
* Validates each table name against `^[a-zA-Z0-9_]+$` + the `wpdo_` prefix
* to prevent accidental DROP of unrelated tables if registry is poisoned.
*
* @return array{tables_dropped:int,options_deleted:int,crons_cleared:int}
* @since 2.14.0
*/
public static function drop_all_tables_for_current_blog(): array {
global $wpdb;
$tables_dropped = 0;
$options_deleted = 0;
$crons_cleared = 0;
// ── 1) Static system + entity flat tables ─────────────────────────────
$tables = array(
$wpdb->prefix . 'wpdo_migrations',
$wpdb->prefix . 'wpdo_errors',
$wpdb->prefix . 'wpdo_benchmarks',
$wpdb->prefix . 'wpdo_warm',
$wpdb->prefix . 'wpdo_archive',
$wpdb->prefix . 'wpdo_audit',
$wpdb->prefix . 'wpdo_shadow_diffs',
$wpdb->prefix . 'wpdo_site_metrics',
$wpdb->prefix . 'wpdo_registry_meta',
$wpdb->prefix . 'wpdo_uni_options',
$wpdb->prefix . 'wpdo_wc_commissions',
$wpdb->prefix . 'wpdo_snapshots',
$wpdb->prefix . 'wpdo_migration_status',
$wpdb->prefix . 'wpdo_user_points_ledger',
$wpdb->prefix . 'wpdo_user_membership',
$wpdb->prefix . 'wpdo_user_activity',
$wpdb->prefix . 'wpdo_user_profile',
$wpdb->prefix . 'wpdo_user_sso',
$wpdb->prefix . 'wpdo_user_core_profile',
$wpdb->prefix . 'wpdo_user_social',
$wpdb->prefix . 'wpdo_user_commerce',
$wpdb->prefix . 'wpdo_user_hp_user',
$wpdb->prefix . 'wpdo_user_admin_prefs',
$wpdb->prefix . 'wpdo_post_wp_core',
$wpdb->prefix . 'wpdo_post_attachment',
$wpdb->prefix . 'wpdo_post_wc_product',
$wpdb->prefix . 'wpdo_post_hp_listing_core',
$wpdb->prefix . 'wpdo_post_hp_request_core',
$wpdb->prefix . 'wpdo_post_hp_vendor_core',
$wpdb->prefix . 'wpdo_post_nav_menu_item',
$wpdb->prefix . 'wpdo_hot_hp_listing',
$wpdb->prefix . 'wpdo_term_hp_taxonomy',
$wpdb->prefix . 'wpdo_comment_hp_review',
$wpdb->prefix . 'wpdo_term_misc',
$wpdb->prefix . 'wpdo_comment_misc',
);
// ── 2) Dynamic zone + entity flat tables (per-site discovery) ─────────
$hot_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_hot_' ) . '%';
$cold_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_cold_' ) . '%';
$entity_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_user_' ) . '%';
$post_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%';
$term_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_term_' ) . '%';
$comment_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_comment_' ) . '%';
if ( class_exists( 'WP_SQLite_Driver' ) ) {
$dynamic = $wpdb->get_col(
$wpdb->prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s)",
$hot_like,
$cold_like,
$entity_like,
$post_ent_like,
$term_ent_like,
$comment_ent_like
)
);
} else {
$dynamic = $wpdb->get_col(
$wpdb->prepare(
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND (TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s)',
$hot_like,
$cold_like,
$entity_like,
$post_ent_like,
$term_ent_like,
$comment_ent_like
)
);
}
$tables = array_unique( array_merge( $tables, $dynamic ?: array() ) );
// ── 3) Validate + filter existing-only + DROP ─────────────────────────
// Pre-check existence so the returned count reflects ACTUAL drops, not
// `DROP IF EXISTS` attempts. `$dynamic` is already pre-filtered (came
// from information_schema), but the static list may contain non-existent
// tables that would inflate the counter.
$prefix = $wpdb->prefix . 'wpdo_';
$valid_static = array();
foreach ( $tables as $table ) {
if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) {
continue;
}
$valid_static[] = $table;
}
if ( ! empty( $valid_static ) ) {
// Single information_schema lookup to confirm which actually exist.
$placeholders = implode( ',', array_fill( 0, count( $valid_static ), '%s' ) );
if ( class_exists( 'WP_SQLite_Driver' ) ) {
$existing = $wpdb->get_col(
$wpdb->prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
...$valid_static
)
);
} else {
$existing = $wpdb->get_col(
$wpdb->prepare(
"SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
...$valid_static
)
);
}
$existing = (array) $existing;
} else {
$existing = array();
}
foreach ( $existing as $table ) {
// Re-validate before raw interpolation as a defence-in-depth measure.
if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) {
continue;
}
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
++$tables_dropped;
}
// ── 4) Clear options ──────────────────────────────────────────────────
$known_options = array(
'wpdo_db_version',
'wpdo_features',
'wpdo_features_shadow',
'wpdo_hpct_imported',
'wpdo_hook_bus_enabled',
'wpdo_v2_features_backup',
'wpdo_v2_upgrade_status',
'wpdo_v2_upgrade_error',
'wpdo_v2_upgraded_at',
'wpdo_health_alert',
'wpdo_rl_stats',
'wpdo_setup_wizard_completed',
'wpdo_first_run_at',
// Stress test state.
'wpdo_post_stress_test_state',
'wpdo_user_stress_test_state',
'wpdo_term_stress_test_state',
'wpdo_comment_stress_test_state',
);
foreach ( $known_options as $opt ) {
if ( delete_option( $opt ) ) {
++$options_deleted;
}
}
// Sweep any remaining wpdo_* options (stragglers introduced post-v2.14.0
// or by 3rd-party hooks). Validates name pattern before delete to avoid
// accidental option removal.
$residual = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like( 'wpdo_' ) . '%'
)
);
foreach ( (array) $residual as $opt ) {
if ( preg_match( '/^wpdo_[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
++$options_deleted;
}
}
// ── 5) Clear cron events ──────────────────────────────────────────────
$crons = array(
'wpdo_warm_cleanup',
'wpdo_archive_sweep',
'wpdo_errors_gc',
'wpdo_flush_views',
'wpdo_remove_uae_plugin_dir',
'wpdo_daily_health_check',
'wpdo_snapshot_prune_daily',
'wpdo_fsm_automator_run',
'wpdo_collect_site_metrics',
'wpdo_health_snapshot_monthly',
'wpdo_post_shadow_verify',
'wpdo_term_comment_shadow_verify',
'wpdo_post_stress_test_batch',
'wpdo_user_stress_test_batch',
'wpdo_term_stress_test_batch',
'wpdo_comment_stress_test_batch',
);
foreach ( $crons as $hook ) {
if ( false !== wp_next_scheduled( $hook ) ) {
wp_clear_scheduled_hook( $hook );
++$crons_cleared;
}
}
return array(
'tables_dropped' => $tables_dropped,
'options_deleted' => $options_deleted,
'crons_cleared' => $crons_cleared,
);
}
// ── Dynamic Zone Table Creation ──────────────────────────────────────
/**
* Ensure the Zone A (hot) table has all columns declared by Schema_Registry.
*
* V2.1.2 critical fix: when a partner plugin registers new hot-zone fields
* AFTER the initial table creation, those columns never make it to the DB
* → WPDO silently falls back to postmeta → zero advertised speedup.
*
* This method:
* 1. Reads current columns from the existing table (SHOW COLUMNS / pragma)
* 2. Diffs against $expected_columns (from Schema_Registry)
* 3. ALTER TABLE ADD COLUMN for any missing columns (idempotent)
* 4. Returns the list of columns that were added (for logging / doctor)
*
* Safe to call on every page load — diff is fast (~0.5ms), ALTER only fires
* on actual drift.
*
* @param string $post_type Post type slug.
* @param array $expected_columns Schema_Registry's declared columns: [name => sql_type].
* @return array<string> Names of columns added (empty when no drift).
*
* @since 2.1.2
*/
public static function ensure_hot_columns( string $post_type, array $expected_columns ): array {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type );
// Skip if table doesn't exist — create_hot_table will handle it.
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var(
$wpdb->prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name=%s", $table )
);
} else {
$exists = $wpdb->get_var(
$wpdb->prepare( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table )
);
}
if ( ! $exists ) {
return array();
}
// Read existing columns.
$existing = array();
if ( TMDO_IS_SQLITE ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results( "PRAGMA table_info(`{$table}`)", ARRAY_A );
foreach ( (array) $rows as $row ) {
$existing[ $row['name'] ] = true;
}
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`", ARRAY_A );
foreach ( (array) $rows as $row ) {
$existing[ $row['Field'] ] = true;
}
}
// Diff: which expected columns are missing from the DB?
$added = array();
foreach ( $expected_columns as $col_name => $col_type ) {
$safe_name = sanitize_key( $col_name );
if ( isset( $existing[ $safe_name ] ) ) {
continue;
}
// MySQL/MariaDB ALTER TABLE ADD COLUMN. SQLite supports the same syntax.
// Suppress PHP warnings during the ALTER — concurrent races on cold deploy
// (N php-fpm workers each detecting drift simultaneously) cause the loser
// to error with "Duplicate column name". We tolerate that case (the column
// IS now present) but surface real failures (disk full, permission denied).
$prev_show = $wpdb->show_errors ?? false;
if ( method_exists( $wpdb, 'hide_errors' ) ) {
$wpdb->hide_errors();
}
self::validate_col_type( $col_type, $safe_name );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$ok = $wpdb->query( "ALTER TABLE `{$table}` ADD COLUMN `{$safe_name}` {$col_type}" );
if ( $prev_show && method_exists( $wpdb, 'show_errors' ) ) {
$wpdb->show_errors();
}
if ( false !== $ok ) {
$added[] = $safe_name;
continue;
}
// v2.1.3 race tolerance: "Duplicate column name" (MySQL errno 1060) means
// another worker won the race. The column IS present now — confirm and treat
// as success. Real failures (errno != 1060, e.g. 1142 access denied, 1114
// table full) still get logged as errors with SQLSTATE/errno for ops.
$err = (string) ( $wpdb->last_error ?? '' );
$is_duplicate = stripos( $err, 'Duplicate column' ) !== false
|| stripos( $err, 'duplicate column' ) !== false
|| stripos( $err, '1060' ) !== false; // MySQL errno.
if ( $is_duplicate ) {
$added[] = $safe_name; // race winner already added it; we're consistent.
continue;
}
TMDO_Logger::error(
'installer',
'ensure_hot_columns',
"Failed to ALTER TABLE {$table} ADD {$safe_name}: {$err}"
);
}
// Re-add covering indexes to pick up any newly indexed columns.
if ( ! empty( $added ) && TMDO_IS_MYSQL ) {
self::add_covering_indexes( $post_type, $expected_columns );
}
return $added;
}
/**
* Create a Zone A (hot) table for a specific post type.
*
* @param string $post_type Post type slug (e.g. 'hp_listing').
* @param array $columns Column definitions from Schema Registry. Format: [ 'column_name' => 'column_type_sql', ... ].
* @return void
*/
public static function create_hot_table( string $post_type, array $columns ): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset = $wpdb->get_charset_collate();
$table_name = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type );
$col_defs = " id bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n";
$col_defs .= " post_id bigint(20) unsigned NOT NULL DEFAULT 0,\n";
$index_defs = array();
$sqlite_cols = array(
array(
'name' => 'id',
'type' => 'bigint',
'nullable' => false,
'default' => null,
),
array(
'name' => 'post_id',
'type' => 'bigint',
'nullable' => false,
'default' => '0',
),
);
foreach ( $columns as $col_name => $col_type ) {
$safe_name = sanitize_key( $col_name );
self::validate_col_type( $col_type, $safe_name );
$col_defs .= " {$safe_name} {$col_type},\n";
$base_type = strtolower( strtok( $col_type, '(' ) );
$sqlite_cols[] = array(
'name' => $safe_name,
'type' => $base_type,
'nullable' => str_contains( strtolower( $col_type ), 'null' ) && ! str_contains( strtolower( $col_type ), 'not null' ),
'default' => '0',
);
}
$col_defs .= " updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',\n";
$sqlite_cols[] = array(
'name' => 'updated_at',
'type' => 'datetime',
'nullable' => false,
'default' => '0000-00-00 00:00:00',
);
$indexes = " PRIMARY KEY (id),\n UNIQUE KEY ui_post_id (post_id)";
foreach ( $index_defs as $idx ) {
$indexes .= ",\n {$idx}";
}
$sql = "CREATE TABLE {$table_name} (\n{$col_defs}{$indexes}\n) {$charset};";
dbDelta( $sql );
if ( TMDO_IS_SQLITE ) {
TMDO_SQLite_Compat::patch_table( $table_name, $sqlite_cols );
}
// Add covering indexes after table creation (MySQL only).
if ( TMDO_IS_MYSQL ) {
self::add_covering_indexes( $post_type, $columns );
}
}
/**
* Add covering indexes to an existing Zone A (hot) table.
*
* Index strategy:
* - DECIMAL/FLOAT columns → single-column idx for range + ORDER BY
* - TINYINT + DECIMAL → compound (flag, sort) for filtered sorts
* - BIGINT *_time + DECIMAL → compound (time, sort) for expiry + price
* - TINYINT matching *_featured + BIGINT *_featured_time → compound (flag, time)
*
* Safe to call multiple times — skips already-existing indexes.
* No-op on SQLite.
*
* @param string $post_type Post type slug.
* @param array $columns Column name => SQL type from Schema Registry.
*/
public static function add_covering_indexes( string $post_type, array $columns ): void {
if ( TMDO_IS_SQLITE ) {
return;
}
global $wpdb;
$table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type );
// Categorise columns by SQL type.
$decimal_cols = array(); // decimal/float/double → good for range + ORDER BY.
$tinyint_cols = array(); // tinyint → boolean flags.
$bigint_time = array(); // bigint with _time suffix → timestamps.
foreach ( $columns as $col_name => $col_type ) {
$safe = sanitize_key( $col_name );
$base = strtolower( strtok( $col_type, '( ' ) );
if ( in_array( $base, array( 'decimal', 'float', 'double' ), true ) ) {
$decimal_cols[] = $safe;
} elseif ( 'tinyint' === $base ) {
$tinyint_cols[] = $safe;
} elseif ( 'bigint' === $base && str_ends_with( $safe, '_time' ) ) {
$bigint_time[] = $safe;
}
}
// Build desired index map: name => ADD INDEX SQL fragment.
$desired = array();
// 1. Single-column index on every decimal column.
foreach ( $decimal_cols as $col ) {
$desired[ "idx_{$col}" ] = "ADD INDEX `idx_{$col}` (`{$col}`)";
}
// 2. Compound (tinyint_flag, decimal_sort) — improves "WHERE flag=1 ORDER BY price".
foreach ( $tinyint_cols as $flag ) {
foreach ( $decimal_cols as $sort ) {
$name = "idx_{$flag}_{$sort}";
$desired[ $name ] = "ADD INDEX `{$name}` (`{$flag}`, `{$sort}`)";
}
}
// 3. Compound (bigint_time, decimal_sort) — improves "WHERE exp_time > ? ORDER BY price".
foreach ( $bigint_time as $time_col ) {
foreach ( $decimal_cols as $sort ) {
$name = "idx_{$time_col}_{$sort}";
$desired[ $name ] = "ADD INDEX `{$name}` (`{$time_col}`, `{$sort}`)";
}
// 4. Compound (matching_tinyint_flag, bigint_time) — "WHERE featured=1 ORDER BY featured_time".
// Matches pattern: tinyint column name is prefix of time column (hp_featured → hp_featured_time).
foreach ( $tinyint_cols as $flag ) {
if ( str_starts_with( $time_col, $flag . '_' ) ) {
$name = "idx_{$flag}_{$time_col}";
$desired[ $name ] = "ADD INDEX `{$name}` (`{$flag}`, `{$time_col}`)";
}
}
}
if ( empty( $desired ) ) {
return;
}
// Apply only missing indexes.
foreach ( $desired as $idx_name => $add_sql ) {
$exists = (int) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s',
$table,
$idx_name
)
);
if ( ! $exists ) {
$wpdb->query( "ALTER TABLE `{$table}` {$add_sql}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- table/index names are validated via sanitize_key(); add_sql contains no user input.
}
}
}
/**
* Create a Zone C (cold) table for a specific post type.
*
* @param string $post_type Post type slug (e.g. 'hp_vendor').
*/
public static function create_cold_table( string $post_type ): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset = $wpdb->get_charset_collate();
$table_name = $wpdb->prefix . 'wpdo_cold_' . sanitize_key( $post_type );
$sql = "CREATE TABLE {$table_name} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
data longtext NOT NULL,
updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
UNIQUE KEY ui_post_id (post_id)
) {$charset};";
dbDelta( $sql );
if ( TMDO_IS_SQLITE ) {
TMDO_SQLite_Compat::patch_table(
$table_name,
array(
array(
'name' => 'id',
'type' => 'bigint',
'nullable' => false,
'default' => null,
),
array(
'name' => 'post_id',
'type' => 'bigint',
'nullable' => false,
'default' => '0',
),
array(
'name' => 'data',
'type' => 'longtext',
'nullable' => false,
'default' => null,
),
array(
'name' => 'updated_at',
'type' => 'datetime',
'nullable' => false,
'default' => '0000-00-00 00:00:00',
),
)
);
}
}
// ── Layer 1: dbDelta ─────────────────────────────────────────────────
/**
* Runs dbDelta to create or upgrade all WPDO tables.
*
* @return void
*/
private static function run_dbdelta(): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset = $wpdb->get_charset_collate();
$p = $wpdb->prefix;
$sqls = array();
// ── wpdo_migrations ──────────────────────────────────────────────
$sqls[] = "CREATE TABLE {$p}wpdo_migrations (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
module varchar(50) NOT NULL DEFAULT '',
zone varchar(10) NOT NULL DEFAULT '',
state varchar(20) NOT NULL DEFAULT 'idle',
total_rows bigint(20) unsigned NOT NULL DEFAULT 0,
processed_rows bigint(20) unsigned NOT NULL DEFAULT 0,
last_offset bigint(20) unsigned NOT NULL DEFAULT 0,
error_count int(11) NOT NULL DEFAULT 0,
started_at datetime NULL DEFAULT NULL,
completed_at datetime NULL DEFAULT NULL,
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY idx_module (module)
) {$charset};";
// ── wpdo_errors ──────────────────────────────────────────────────
$sqls[] = "CREATE TABLE {$p}wpdo_errors (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
module varchar(50) NOT NULL DEFAULT '',
zone varchar(10) NOT NULL DEFAULT '',
hook varchar(255) NOT NULL DEFAULT '',
message longtext NOT NULL,
context longtext,
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY idx_module (module),
KEY idx_created_at (created_at)
) {$charset};";
// ── wpdo_benchmarks ──────────────────────────────────────────────
$sqls[] = "CREATE TABLE {$p}wpdo_benchmarks (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
module varchar(50) NOT NULL DEFAULT '',
zone varchar(10) NOT NULL DEFAULT '',
query_type varchar(50) NOT NULL DEFAULT '',
native_ms decimal(10,3) NOT NULL DEFAULT 0,
custom_ms decimal(10,3) NOT NULL DEFAULT 0,
sample_size int(11) NOT NULL DEFAULT 0,
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY idx_module (module)
) {$charset};";
// ── wpdo_warm (Zone B) ───────────────────────────────────────────
$sqls[] = "CREATE TABLE {$p}wpdo_warm (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(191) NOT NULL DEFAULT '',
meta_value longtext,
expires_at datetime NULL DEFAULT NULL,
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
UNIQUE KEY ui_post_meta (post_id, meta_key),
KEY idx_expires_at (expires_at)
) {$charset};";
// ── wpdo_archive (Zone D) ────────────────────────────────────────
$sqls[] = "CREATE TABLE {$p}wpdo_archive (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
post_type varchar(20) NOT NULL DEFAULT '',
meta_key varchar(255) NOT NULL DEFAULT '',
meta_value longtext,
compressed tinyint(1) NOT NULL DEFAULT 0,
archived_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
original_meta_id bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_post_id (post_id),
KEY idx_post_type_date (post_type, archived_at)
) {$charset};";
foreach ( $sqls as $sql ) {
dbDelta( $sql );
}
}
// ── Layer 2: MySQL-only composite indexes ────────────────────────────
/**
* Adds MySQL-only composite indexes if they do not already exist.
*
* @return void
*/
private static function run_mysql_indexes(): void {
global $wpdb;
$p = $wpdb->prefix;
$indexes = array(
"{$p}wpdo_migrations" => array(
'ui_module' => "ALTER TABLE `{$p}wpdo_migrations` ADD UNIQUE KEY `ui_module` (module)",
),
"{$p}wpdo_warm" => array(
'ui_post_meta' => "ALTER TABLE `{$p}wpdo_warm` ADD UNIQUE KEY `ui_post_meta` (post_id, meta_key(191))",
),
);
foreach ( $indexes as $table => $defs ) {
foreach ( $defs as $key_name => $sql ) {
$exists = $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s',
$table,
$key_name
)
);
if ( ! $exists ) {
$wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
}
}
}
}