fa31a0527b
- TMDO_Installer::ALLOWED_COL_BASE_TYPES(19 型別)+ validate_col_type(), 在 ALTER TABLE ADD COLUMN 與 CREATE TABLE 組欄位前驗證:先前 partner plugin 經 Schema Registry 提供的 $col_type 直接拼進 DDL。 - TMDO_CLI::assert_safe_table_name(),守 doctor 的 SHOW COLUMNS / PRAGMA 與 benchmark 的 COUNT(*) 三處無法 prepare 的識別字。 對應 A v3.3.4。unit 379 / integration 398 GREEN Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
1395 lines
48 KiB
PHP
1395 lines
48 KiB
PHP
<?php
|
||
/**
|
||
* Database table installer for WP Data Optimizer.
|
||
*
|
||
* @package WP_Data_Optimizer
|
||
*/
|
||
|
||
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(255) 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),
|
||
KEY idx_post_meta (post_id, meta_key(191)),
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|