Files
2meet-data-optimizer/cli/class-tmdo-cli.php
wpdev e33ae4e626
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 9s
Tests / Unit Tests (push) Successful in 9s
Tests / Integration Tests (push) Successful in 31s
Tests / PHP Lint (push) Successful in 8s
Tests / PHPCS (push) Successful in 20s
Tests / PHPStan (push) Successful in 24s
test(boundary): 新增 CoreBoundaryTest,並補完 PR-I 的兩個缺漏測試
CoreBoundaryTest 靜態掃描核心 126 個生產檔,斷言每一處對 AddOn 類別的
static 呼叫都在同一個函式內有 class_exists() 守衛。上一個 commit 修的
TMDO_Listing_Stats fatal 就是這類缺陷,這個測試讓它不會再回來。

它當場又抓到 3 處同類違規(都是實際會 fatal 的路徑),一併修掉:
- admin render_hpct_import():改印 admin notice 並 return
- wp tmdo import-hpct:改 WP_CLI::error 明示需要 hivepress-addon
- cli-post cleanup-hp-transients 其實早有守衛,是測試的行距啟發式太窄;
  判斷範圍改成「同一個函式內」而非固定 12 行

負向驗證:暫時注入一處無守衛呼叫 → 測試如預期失敗;還原後回綠。

同時補完計畫階段 7 PR-I 列的兩個缺漏測試:
- 核心 tests/unit/StandardPostInterceptorTest.php(10 tests)
- HP AddOn tests/unit/ListingStatsTest.php(9 tests)——AddOn 的 unit
  bootstrap 先前刻意不載入真實 TMDO_Listing_Stats,改以
  TMDO_TEST_SKIP_LISTING_STATS_STUB 常數讓它跳過核心的 stub
- 核心 unit bootstrap 補 add_post_meta() stub(flush 路徑用得到)

核心 unit 451 → 587、HP AddOn 145 → 154。
2026-07-31 10:41:13 +08:00

1834 lines
62 KiB
PHP

<?php
/**
* WP-CLI commands for WP Data Optimizer.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WP-CLI commands for WP Data Optimizer.
*
* Usage: wp wpdo <subcommand>
*/
class TMDO_CLI {
/**
* Show status of all modules and zones.
*
* ## EXAMPLES
*
* wp wpdo status
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand status
*/
public function status( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
WP_CLI::log( '=== WP Data Optimizer Status ===' );
WP_CLI::log( '' );
WP_CLI::log( 'Database Engine: ' . ( TMDO_IS_MYSQL ? 'MySQL' : 'SQLite' ) );
WP_CLI::log( 'Plugin Version: ' . TMDO_VERSION );
WP_CLI::log( 'DB Version: ' . get_option( 'wpdo_db_version', 'N/A' ) );
WP_CLI::log( '' );
$compat = TMDO_Compatibility::check();
WP_CLI::log( 'HivePress: ' . ( $compat['hivepress'] ? 'Active' : 'Not found' ) );
WP_CLI::log( 'HP Custom Tables: ' . self::format_hpct_status( $compat ) );
WP_CLI::log( 'Object Cache: ' . ( wp_using_ext_object_cache() ? 'External' : 'Built-in' ) );
WP_CLI::log( '' );
$stats = TMDO_Schema_Registry::instance()->get_stats();
WP_CLI::log( 'Registered Fields:' );
WP_CLI::log( " Hot (A): {$stats['hot']}" );
WP_CLI::log( " Warm (B): {$stats['warm']}" );
WP_CLI::log( " Cold (C): {$stats['cold']}" );
WP_CLI::log( " Archive (D): {$stats['archive']}" );
WP_CLI::log( " Total: {$stats['total']}" );
WP_CLI::log( '' );
WP_CLI::log( 'Module States:' );
$hpct_flags = TMDO_Feature_Flags::hpct_modules();
if ( ! empty( $hpct_flags ) ) {
WP_CLI::log( ' [HPCT Modules]' );
foreach ( $hpct_flags as $module => $state ) {
WP_CLI::log( " {$module}: {$state}" );
}
}
$zone_flags = TMDO_Feature_Flags::zone_modules();
if ( ! empty( $zone_flags ) ) {
WP_CLI::log( ' [Zone Modules]' );
foreach ( $zone_flags as $module => $state ) {
WP_CLI::log( " {$module}: {$state}" );
}
}
WP_CLI::success( 'Status complete.' );
}
/**
* Install or upgrade database tables.
*
* ## EXAMPLES
*
* wp wpdo install
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand install
*/
public function install( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
WP_CLI::log( 'Installing WPDO tables...' );
TMDO_Installer::install();
WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) );
}
/**
* Run health check on all tables.
*
* ## EXAMPLES
*
* wp wpdo doctor
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand doctor
*/
public function doctor( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
global $wpdb;
WP_CLI::log( 'Running health check...' );
$required_tables = array(
'wpdo_migrations',
'wpdo_errors',
'wpdo_benchmarks',
'wpdo_warm',
'wpdo_archive',
);
$all_ok = true;
foreach ( $required_tables as $table ) {
$full_name = $wpdb->prefix . $table;
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $full_name ) );
} else {
$exists = $wpdb->get_var(
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $full_name )
);
}
if ( $exists ) {
WP_CLI::log( " [OK] {$full_name}" );
} else {
WP_CLI::warning( " [MISSING] {$full_name}" );
$all_ok = false;
}
}
// Check dynamic zone tables.
$registry = TMDO_Schema_Registry::instance();
$hot_types = $registry->get_hot_post_types();
foreach ( $hot_types as $pt ) {
if ( '' === $pt ) {
// Skip the bogus empty-string post_type bucket (legacy artifact pre-v2.1.2 normalization).
continue;
}
$t = TMDO_Zone_Hot::table( $pt );
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
} else {
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
}
if ( ! $exists ) {
WP_CLI::log( " [NOT CREATED] {$t} (will be created on first use)" );
continue;
}
// v2.1.2 doctor column-drift check: compare DB columns vs Schema_Registry declared columns.
$declared = array_keys( $registry->get_hot_columns( $pt ) );
$existing = array();
self::assert_safe_table_name( $t );
if ( TMDO_IS_MYSQL ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_col( "SHOW COLUMNS FROM `{$t}`" );
$existing = is_array( $rows ) ? $rows : array();
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results( "PRAGMA table_info(`{$t}`)", ARRAY_A );
foreach ( (array) $rows as $row ) {
$existing[] = $row['name'];
}
}
$missing = array_diff( $declared, $existing );
if ( empty( $missing ) ) {
WP_CLI::log( " [OK] {$t}" );
} else {
WP_CLI::warning( " [DRIFT] {$t} — missing columns: " . implode( ', ', $missing ) . ' (run a write to trigger ensure_hot_columns auto-fix)' );
$all_ok = false;
}
}
$cold_types = $registry->get_cold_post_types();
foreach ( $cold_types as $pt ) {
$t = TMDO_Zone_Cold::table( $pt );
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
} else {
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
}
WP_CLI::log( $exists ? " [OK] {$t}" : " [NOT CREATED] {$t} (will be created on first use)" );
}
// Check user entity flat tables (v2.5.5+).
$user_entity_tables = array(
$wpdb->prefix . 'wpdo_user_membership',
$wpdb->prefix . 'wpdo_user_activity',
$wpdb->prefix . 'wpdo_user_profile',
$wpdb->prefix . 'wpdo_user_sso',
$wpdb->prefix . 'wpdo_user_points_ledger',
$wpdb->prefix . 'wpdo_migration_status',
);
foreach ( $user_entity_tables as $t ) {
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
} else {
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
}
if ( $exists ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
} else {
WP_CLI::warning( " [MISSING] {$t} — run \`wp wpdo install\` to create." );
$all_ok = false;
}
}
// Check partner plugin custom tables via Custom Table Registry (v2.6.0+).
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
$ctr = TMDO_Custom_Table_Registry::instance();
$tables = $ctr->all();
if ( ! empty( $tables ) ) {
WP_CLI::log( '' );
WP_CLI::log( 'Partner plugin custom tables (' . count( $tables ) . ' registered):' );
$current_provider = '';
foreach ( $tables as $cfg ) {
$provider = $cfg['provider'];
$tbl_raw = $cfg['table_name'];
$full_name = $wpdb->prefix . $tbl_raw;
if ( $provider !== $current_provider ) {
WP_CLI::log( " [{$provider}]" );
$current_provider = $provider;
}
// Existence check.
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $full_name ) );
} else {
$exists = $wpdb->get_var(
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $full_name )
);
}
if ( ! $exists ) {
WP_CLI::warning( " [MISSING] {$full_name}" );
$all_ok = false;
continue;
}
// Row count.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$full_name}`" );
// Optional doctor_callback. Contract is a single argument: the raw
// table suffix as registered (A v3.0.3 fix — passing rows/full_name
// broke partner callbacks with a stricter signature).
$cb = $cfg['doctor_callback'] ?? null;
if ( is_callable( $cb ) ) {
try {
$result = call_user_func( $cb, $tbl_raw );
$cb_ok = (bool) ( $result['ok'] ?? true );
$cb_msg = (string) ( $result['message'] ?? '' );
$status = $cb_ok ? '[OK]' : '[WARN]';
$detail = '' !== $cb_msg ? "{$cb_msg}" : '';
$line = " {$status} {$full_name} ({$rows} rows){$detail}";
$cb_ok ? WP_CLI::log( $line ) : WP_CLI::warning( $line );
} catch ( \Throwable $e ) {
WP_CLI::warning( " [WARN] {$full_name} ({$rows} rows) — doctor_callback threw: " . $e->getMessage() );
}
} else {
WP_CLI::log( " [OK] {$full_name} ({$rows} rows)" );
}
}
}
}
// ── Backup directory security check ─────────────────────────────────────
$backup_dir = TMDO_Snapshot_Manager::backup_dir();
if ( file_exists( $backup_dir ) && is_dir( $backup_dir ) ) {
$htaccess_ok = file_exists( $backup_dir . '/.htaccess' );
$has_sql_file = (bool) glob( $backup_dir . '/*.sql' );
// Probe via HTTP — a properly blocked dir returns 403/404; 200 is a red flag.
$probe_url = trailingslashit( wp_upload_dir()['baseurl'] ) . TMDO_Snapshot_Manager::BACKUP_DIR_NAME . '/index.php';
$response = wp_remote_get(
$probe_url,
array(
'timeout' => 5,
'user-agent' => 'TMDO-Doctor/1.0',
'sslverify' => false,
)
);
$http_code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
if ( 200 === $http_code ) {
WP_CLI::warning( " [WARN] Backup dir is HTTP-accessible ({$probe_url} → 200)." );
WP_CLI::warning( ' For nginx, add: location ~* /wpdo-backups/ { deny all; }' );
$all_ok = false;
} elseif ( in_array( $http_code, array( 403, 404 ), true ) ) {
WP_CLI::log( " [OK] Backup dir blocked (HTTP {$http_code})" );
} elseif ( 0 === $http_code ) {
if ( $htaccess_ok ) {
WP_CLI::log( ' [OK] Backup dir has .htaccess deny rule (HTTP probe failed — offline or CLI-only mode)' );
} else {
WP_CLI::warning( ' [WARN] Backup dir missing .htaccess — run `wp tmdo install` to regenerate.' );
$all_ok = false;
}
}
if ( $has_sql_file && ! $htaccess_ok ) {
WP_CLI::warning( ' [WARN] SQL backup files present but .htaccess missing.' );
$all_ok = false;
}
}
// ── Crypto key health check ─────────────────────────────────────────────
if ( ! TMDO_Crypto::is_key_derivable() ) {
WP_CLI::warning( ' [WARN] AUTH_KEY and SECURE_AUTH_SALT are both absent or empty — TMDO_Crypto cannot derive an encryption key. Notifier secrets will be stored as plaintext. Set these constants in wp-config.php.' );
$all_ok = false;
} else {
WP_CLI::log( ' [OK] Crypto key derivable (AUTH_KEY / SECURE_AUTH_SALT present)' );
}
// Check recent errors.
$errors = TMDO_Logger::get_recent( '', 5 );
if ( ! empty( $errors ) ) {
WP_CLI::log( '' );
WP_CLI::log( 'Recent errors:' );
foreach ( $errors as $err ) {
WP_CLI::log( " [{$err['module']}] {$err['message']} ({$err['created_at']})" );
}
}
if ( $all_ok ) {
WP_CLI::success( 'All checks passed.' );
} else {
WP_CLI::warning( 'Some checks failed. Run `wp wpdo install` to fix.' );
}
}
/**
* Analyze postmeta and suggest zone classifications.
*
* ## OPTIONS
*
* [--post-type=<type>]
* : Post type to analyze. If omitted, analyzes hp_listing.
*
* [--format=<format>]
* : Output format (table or json). Default: table.
*
* ## EXAMPLES
*
* wp wpdo analyze --post-type=hp_listing
* wp wpdo analyze --post-type=hp_vendor --format=json
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments (post-type, format).
* @return void
*
* @subcommand analyze
*/
public function analyze( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$post_type = sanitize_key( $assoc_args['post-type'] ?? 'hp_listing' );
$format = in_array( $assoc_args['format'] ?? 'table', array( 'table', 'json' ), true ) ? $assoc_args['format'] : 'table';
WP_CLI::log( "Analyzing postmeta for post type: {$post_type}..." );
$suggestions = TMDO_Zone_Classifier::analyze( $post_type );
if ( empty( $suggestions ) ) {
WP_CLI::warning( "No postmeta found for post type '{$post_type}'." );
return;
}
if ( 'json' === $format ) {
WP_CLI::log( wp_json_encode( $suggestions, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ) );
return;
}
$table_data = array();
foreach ( $suggestions as $s ) {
$table_data[] = array(
'meta_key' => $s['meta_key'],
'rows' => $s['row_count'],
'suggested' => $s['suggested_zone'],
'confidence' => $s['confidence'],
'assigned' => $s['already_assigned'] ?: '—',
'reason' => implode( '; ', $s['reasons'] ),
);
}
WP_CLI\Utils\format_items( 'table', $table_data, array( 'meta_key', 'rows', 'suggested', 'confidence', 'assigned', 'reason' ) );
$summary = TMDO_Zone_Classifier::summary( $post_type );
WP_CLI::log( '' );
WP_CLI::log( "Summary: Hot={$summary['hot']}, Warm={$summary['warm']}, Cold={$summary['cold']}, Archive={$summary['archive']}, Already assigned={$summary['already_assigned']}" );
WP_CLI::success( 'Analysis complete.' );
}
/**
* Run data migration for a module.
*
* ## OPTIONS
*
* <module>
* : Module name (e.g., hot_hp_listing, warm, cold_hp_vendor, archive).
*
* [--resume]
* : Resume an interrupted migration instead of starting fresh.
*
* ## EXAMPLES
*
* wp wpdo migrate hot_hp_listing
* wp wpdo migrate warm --resume
* wp wpdo migrate archive
*
* @param array $args Positional arguments (module name).
* @param array $assoc_args Associative arguments (resume flag).
* @return void
*
* @subcommand migrate
*/
public function migrate( $args, $assoc_args ): void {
$module = sanitize_key( $args[0] ?? '' );
$resume = isset( $assoc_args['resume'] );
if ( empty( $module ) ) {
WP_CLI::error( 'Module name required. Examples: hot_hp_listing, warm, cold_hp_vendor, archive' );
}
$migration = self::get_migration_instance( $module );
if ( ! $migration ) {
WP_CLI::error( "Unknown module: {$module}" );
}
WP_CLI::log( "Starting migration for module: {$module}" . ( $resume ? ' (resuming)' : '' ) );
$progress = null;
$completed = $migration->run(
$resume,
function ( $processed, $total ) use ( &$progress ) {
if ( null === $progress && $total > 0 ) {
$progress = \WP_CLI\Utils\make_progress_bar( 'Migrating', $total );
}
if ( $progress ) {
$progress->tick();
}
}
);
if ( $progress ) {
$progress->finish();
}
if ( $completed ) {
WP_CLI::success( "Migration complete for {$module}. State: verify. Run `wp wpdo verify {$module}` next." );
} else {
$record = $migration->get_record();
$processed = $record ? (int) $record['processed_rows'] : 0;
$total = $record ? (int) $record['total_rows'] : 0;
WP_CLI::warning( "Migration timed out ({$processed}/{$total}). Run `wp wpdo migrate {$module} --resume` to continue." );
}
}
/**
* Verify data consistency for a module.
*
* ## OPTIONS
*
* <module>
* : Module name.
*
* ## EXAMPLES
*
* wp wpdo verify hot_hp_listing
*
* @param array $args Positional arguments (module name).
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand verify
*/
public function verify( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$module = sanitize_key( $args[0] ?? '' );
if ( empty( $module ) ) {
WP_CLI::error( 'Module name required.' );
}
$migration = self::get_migration_instance( $module );
if ( ! $migration ) {
WP_CLI::error( "Unknown module: {$module}" );
}
WP_CLI::log( "Verifying data consistency for: {$module}..." );
if ( $migration->verify_counts() ) {
WP_CLI::success( "Verification passed for {$module}. Run `wp wpdo cutover {$module}` to switch reads." );
} else {
WP_CLI::warning( "Verification failed — row counts don't match. Re-run migration or investigate." );
}
}
/**
* Switch reads to custom table (cutover).
*
* ## OPTIONS
*
* <module>
* : Module name.
*
* ## EXAMPLES
*
* wp wpdo cutover hot_hp_listing
*
* @param array $args Positional arguments (module name).
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand cutover
*/
public function cutover( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$module = sanitize_key( $args[0] ?? '' );
if ( empty( $module ) ) {
WP_CLI::error( 'Module name required.' );
}
$state = TMDO_Feature_Flags::get( $module );
if ( ! in_array( $state, array( 'verify', 'dual_write', 'backfill' ), true ) ) {
WP_CLI::error( "Module {$module} is in state '{$state}' — cutover requires verify, dual_write, or backfill state." );
}
TMDO_Feature_Flags::set( $module, 'cutover' );
WP_CLI::success( "Module {$module} switched to cutover. Reads now come from custom table." );
}
/**
* Rollback a module to idle (reads from postmeta).
*
* ## OPTIONS
*
* <module>
* : Module name.
*
* ## EXAMPLES
*
* wp wpdo rollback hot_hp_listing
*
* @param array $args Positional arguments (module name).
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand rollback
*/
public function rollback( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$module = sanitize_key( $args[0] ?? '' );
if ( empty( $module ) ) {
WP_CLI::error( 'Module name required.' );
}
WP_CLI::confirm( "Are you sure you want to rollback module '{$module}' to idle?" );
TMDO_Feature_Flags::reset( $module );
WP_CLI::success( "Module {$module} rolled back to idle." );
}
/**
* Mark a module as complete (reads and writes on custom table only).
*
* ## OPTIONS
*
* <module>
* : Module name.
*
* ## EXAMPLES
*
* wp wpdo enable hot_hp_listing
*
* @param array $args Positional arguments (module name).
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand enable
*/
public function enable( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$module = sanitize_key( $args[0] ?? '' );
if ( empty( $module ) ) {
WP_CLI::error( 'Module name required.' );
}
TMDO_Feature_Flags::set( $module, 'complete' );
WP_CLI::success( "Module {$module} set to complete." );
}
/**
* Disable a module (set to idle).
*
* ## OPTIONS
*
* <module>
* : Module name.
*
* ## EXAMPLES
*
* wp wpdo disable hot_hp_listing
*
* @param array $args Positional arguments (module name).
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand disable
*/
public function disable( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$module = sanitize_key( $args[0] ?? '' );
if ( empty( $module ) ) {
WP_CLI::error( 'Module name required.' );
}
TMDO_Feature_Flags::reset( $module );
WP_CLI::success( "Module {$module} disabled (idle)." );
}
/**
* Import settings from HP Custom Tables.
*
* ## EXAMPLES
*
* wp wpdo import-hpct
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand import-hpct
*/
public function import_hpct( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
// HPCT is a HivePress-family plugin; the importer ships in that AddOn.
if ( ! class_exists( 'TMDO_HPCT_Import' ) ) {
WP_CLI::error( 'import-hpct needs 2meet-data-optimizer-hivepress-addon to be active.' );
}
if ( TMDO_HPCT_Import::is_imported() ) {
WP_CLI::warning( 'HPCT settings have already been imported.' );
return;
}
if ( ! TMDO_HPCT_Import::can_import() ) {
WP_CLI::error( 'HP Custom Tables plugin not detected or already imported.' );
}
// Show preview.
$preview_data = TMDO_HPCT_Import::preview();
if ( ! empty( $preview_data['modules'] ) ) {
WP_CLI::log( 'Import preview:' );
WP_CLI\Utils\format_items( 'table', $preview_data['modules'], array( 'module', 'hpct_status', 'wpdo_state' ) );
}
WP_CLI::confirm( 'Proceed with import?' );
$result = TMDO_HPCT_Import::run();
if ( is_wp_error( $result ) ) {
WP_CLI::error( $result->get_error_message() );
}
WP_CLI::success( 'HPCT settings imported successfully. You can now deactivate HP Custom Tables.' );
}
/**
* Run performance benchmark.
*
* ## OPTIONS
*
* [<module>]
* : Optional module name to benchmark.
*
* [--samples=<n>]
* : Number of samples. Default: 50.
*
* [--custom-tables]
* : Also benchmark all partner plugin custom tables registered in TMDO_Custom_Table_Registry.
*
* ## EXAMPLES
*
* wp wpdo benchmark
* wp wpdo benchmark hot_hp_listing --samples=100
* wp wpdo benchmark --custom-tables
*
* @param array $args Positional arguments (optional module name).
* @param array $assoc_args Associative arguments (samples, custom-tables).
* @return void
*
* @subcommand benchmark
*/
public function benchmark( $args, $assoc_args ): void {
$module = sanitize_key( $args[0] ?? '' );
$samples = absint( $assoc_args['samples'] ?? 50 );
$custom_tables = ! empty( $assoc_args['custom-tables'] );
WP_CLI::log( 'Running benchmark' . ( $module ? " for {$module}" : '' ) . " ({$samples} samples)..." );
global $wpdb;
// Benchmark postmeta reads vs zone reads.
$registry = TMDO_Schema_Registry::instance();
$hot_types = $registry->get_hot_post_types();
if ( empty( $hot_types ) ) {
WP_CLI::warning( 'No hot zone post types registered. Register fields first.' );
return;
}
foreach ( $hot_types as $pt ) {
if ( $module && "hot_{$pt}" !== $module ) {
continue;
}
$columns = $registry->get_hot_columns( $pt );
if ( empty( $columns ) ) {
continue;
}
// Get sample post IDs.
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' LIMIT %d",
$pt,
$samples
)
);
if ( empty( $post_ids ) ) {
WP_CLI::log( " {$pt}: No published posts found." );
continue;
}
$fields = $registry->get_zone_fields_for_type( 'hot', $pt );
$first_field = reset( $fields );
$meta_key = $first_field['meta_key'];
$column = $first_field['column'];
// Native benchmark.
$start = microtime( true );
foreach ( $post_ids as $pid ) {
get_post_meta( (int) $pid, $meta_key, true );
}
$native_ms = ( microtime( true ) - $start ) * 1000;
// Zone benchmark.
$start = microtime( true );
foreach ( $post_ids as $pid ) {
TMDO_Zone_Hot::get( (int) $pid, $pt, $column );
}
$zone_ms = ( microtime( true ) - $start ) * 1000;
$speedup = $native_ms > 0 ? round( $native_ms / max( $zone_ms, 0.001 ), 1 ) : 'N/A';
WP_CLI::log( " hot_{$pt} ({$meta_key}):" );
WP_CLI::log( ' Native (postmeta): ' . round( $native_ms, 2 ) . ' ms' );
WP_CLI::log( ' Zone A (hot): ' . round( $zone_ms, 2 ) . ' ms' );
WP_CLI::log( " Speedup: {$speedup}x" );
// Save to benchmarks table.
$bench_table = TMDO_DB::table( 'wpdo_benchmarks' );
$wpdb->insert(
$bench_table,
array(
'module' => "hot_{$pt}",
'zone' => 'hot',
'query_type' => 'single_read',
'native_ms' => round( $native_ms, 3 ),
'custom_ms' => round( $zone_ms, 3 ),
'sample_size' => count( $post_ids ),
'created_at' => TMDO_DB::now(),
),
array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
);
}
// ── Zone B (Warm) benchmark ─────────────────────────────────────────────
if ( ! $module || 'warm_hp_listing' === $module ) {
$warm_posts = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' LIMIT %d",
'hp_listing',
$samples
)
);
if ( ! empty( $warm_posts ) ) {
// Seed warm zone entries so reads are non-trivial.
foreach ( $warm_posts as $pid ) {
TMDO_Zone_Warm::set( (int) $pid, TMDO_Zone_Warm::VIEW_KEY, '1', DAY_IN_SECONDS );
}
$start = microtime( true );
foreach ( $warm_posts as $pid ) {
TMDO_Zone_Warm::get( (int) $pid, TMDO_Zone_Warm::VIEW_KEY );
}
$warm_ms = ( microtime( true ) - $start ) * 1000;
$start = microtime( true );
foreach ( $warm_posts as $pid ) {
get_post_meta( (int) $pid, 'hp_view_count', true );
}
$native_warm_ms = ( microtime( true ) - $start ) * 1000;
$speedup = $native_warm_ms > 0 ? round( $native_warm_ms / max( $warm_ms, 0.001 ), 1 ) : 'N/A';
WP_CLI::log( '' );
WP_CLI::log( ' warm_hp_listing (view count reads):' );
WP_CLI::log( ' Native (postmeta): ' . round( $native_warm_ms, 2 ) . ' ms' );
WP_CLI::log( ' Zone B (warm): ' . round( $warm_ms, 2 ) . ' ms' );
WP_CLI::log( ' Speedup: ' . $speedup . 'x' );
$bench_table = TMDO_DB::table( 'wpdo_benchmarks' );
$wpdb->insert(
$bench_table,
array(
'module' => 'warm_hp_listing',
'zone' => 'warm',
'query_type' => 'view_count_read',
'native_ms' => round( $native_warm_ms, 3 ),
'custom_ms' => round( $warm_ms, 3 ),
'sample_size' => count( $warm_posts ),
'created_at' => TMDO_DB::now(),
),
array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
);
} else {
WP_CLI::log( ' warm_hp_listing: No published hp_listing posts found.' );
}
}
// ── Zone C (Cold) benchmark ──────────────────────────────────────────────
$cold_types = $registry->get_cold_post_types();
foreach ( $cold_types as $pt ) {
if ( $module && "cold_{$pt}" !== $module ) {
continue;
}
$cold_meta_keys = $registry->get_cold_meta_keys( $pt );
if ( empty( $cold_meta_keys ) ) {
continue;
}
$cold_post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' LIMIT %d",
$pt,
$samples
)
);
if ( empty( $cold_post_ids ) ) {
WP_CLI::log( " cold_{$pt}: No published posts found." );
continue;
}
// Zone C: one get_blob (JSON decode) vs N postmeta reads per post.
$start = microtime( true );
foreach ( $cold_post_ids as $pid ) {
TMDO_Zone_Cold::get_blob( (int) $pid, $pt );
}
$cold_ms = ( microtime( true ) - $start ) * 1000;
$start = microtime( true );
foreach ( $cold_post_ids as $pid ) {
foreach ( $cold_meta_keys as $key ) {
get_post_meta( (int) $pid, $key, true );
}
}
$native_cold_ms = ( microtime( true ) - $start ) * 1000;
$key_count = count( $cold_meta_keys );
$speedup = $native_cold_ms > 0 ? round( $native_cold_ms / max( $cold_ms, 0.001 ), 1 ) : 'N/A';
WP_CLI::log( '' );
WP_CLI::log( " cold_{$pt} ({$key_count} keys per post):" );
WP_CLI::log( ' Native (postmeta): ' . round( $native_cold_ms, 2 ) . ' ms' );
WP_CLI::log( ' Zone C (cold): ' . round( $cold_ms, 2 ) . ' ms' );
WP_CLI::log( ' Speedup: ' . $speedup . 'x' );
$bench_table = TMDO_DB::table( 'wpdo_benchmarks' );
$wpdb->insert(
$bench_table,
array(
'module' => "cold_{$pt}",
'zone' => 'cold',
'query_type' => 'blob_read',
'native_ms' => round( $native_cold_ms, 3 ),
'custom_ms' => round( $cold_ms, 3 ),
'sample_size' => count( $cold_post_ids ),
'created_at' => TMDO_DB::now(),
),
array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
);
}
// ── Custom tables benchmark ───────────────────────────────────────
if ( $custom_tables ) {
$this->benchmark_custom_tables( $samples );
}
WP_CLI::success( 'Benchmark complete. Results saved to wpdo_benchmarks table.' );
}
/**
* Benchmark all partner plugin custom tables from TMDO_Custom_Table_Registry.
*
* For tables that supply a `benchmark_callback`, the callback is invoked
* and its result used directly. For tables without a callback a built-in
* generic probe is run: COUNT(*) + a LIMIT-N sequential read.
*
* @param int $samples Number of rows to read in the sequential read probe.
* @return void
*/
private function benchmark_custom_tables( int $samples ): void {
global $wpdb;
$registry = TMDO_Custom_Table_Registry::instance();
$all = $registry->all();
if ( empty( $all ) ) {
WP_CLI::log( '' );
WP_CLI::warning( 'No custom tables registered. Partner plugins may not be active.' );
return;
}
WP_CLI::log( '' );
WP_CLI::log( sprintf( '── Custom table benchmark (%d tables, %d samples) ──', count( $all ), $samples ) );
$bench_table = TMDO_DB::table( 'wpdo_benchmarks' );
$current_provider = '';
foreach ( $all as $key => $cfg ) {
$provider = (string) $cfg['provider'];
$table_name = (string) $cfg['table_name'];
$full_table = $wpdb->prefix . $table_name;
if ( $provider !== $current_provider ) {
WP_CLI::log( '' );
WP_CLI::log( " [{$provider}]" );
$current_provider = $provider;
}
// Check table exists.
$exists = (int) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$full_table
)
); // phpcs:ignore WordPress.DB
if ( ! $exists ) {
WP_CLI::log( " {$table_name}: [MISSING]" );
continue;
}
self::assert_safe_table_name( $full_table );
$total_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$full_table}`" ); // phpcs:ignore WordPress.DB
// Use partner-supplied callback when available.
if ( is_callable( $cfg['benchmark_callback'] ?? null ) ) {
try {
$result = call_user_func( $cfg['benchmark_callback'], $samples );
$duration = (float) ( $result['duration_ms'] ?? 0 );
$sample_n = (int) ( $result['sample_size'] ?? $samples );
$query_type = (string) ( $result['query_type'] ?? 'custom_callback' );
WP_CLI::log( sprintf( ' %s: %s rows | callback %.2fms (%d samples)', $table_name, number_format( $total_rows ), $duration, $sample_n ) );
} catch ( \Throwable $e ) {
WP_CLI::warning( " {$table_name}: benchmark_callback threw: " . $e->getMessage() );
continue;
}
} else {
// Built-in generic probe: time a COUNT(*) + a sequential LIMIT read.
$probe_n = min( $samples, $total_rows );
$query_type = 'generic_read';
// Count timing.
$start = microtime( true );
$wpdb->get_var( "SELECT COUNT(*) FROM `{$full_table}`" ); // phpcs:ignore WordPress.DB
$count_ms = ( microtime( true ) - $start ) * 1000;
// Sequential read timing.
$start = microtime( true );
$pk = sanitize_key( (string) ( $cfg['primary_key'] ?? 'id' ) );
$wpdb->get_results( // phpcs:ignore WordPress.DB
$wpdb->prepare( "SELECT * FROM `{$full_table}` ORDER BY `{$pk}` LIMIT %d", $probe_n ) // phpcs:ignore WordPress.DB
);
$read_ms = ( microtime( true ) - $start ) * 1000;
$duration = $count_ms + $read_ms;
WP_CLI::log(
sprintf(
' %s: %s rows | count %.2fms | read(%d rows) %.2fms',
$table_name,
number_format( $total_rows ),
$count_ms,
$probe_n,
$read_ms
)
);
}
$wpdb->insert(
$bench_table,
array(
'module' => "custom_{$table_name}",
'zone' => 'custom',
'query_type' => $query_type,
'native_ms' => 0,
'custom_ms' => round( $duration, 3 ),
'sample_size' => $total_rows,
'created_at' => TMDO_DB::now(),
),
array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
);
}
}
/**
* Show site-wide EAV health metrics (latest snapshot + optional history).
*
* ## OPTIONS
*
* [--collect]
* : Force-collect a fresh snapshot now (does not wait for cron).
*
* [--history=<metric_key>]
* : Show 30-day daily history for a specific metric key (e.g. eav.postmeta_rows).
*
* [--days=<n>]
* : Number of days to show in history mode. Default: 30.
*
* [--format=<format>]
* : Output format (table, json, csv). Default: table.
*
* ## EXAMPLES
*
* wp wpdo site-metrics
* wp wpdo site-metrics --collect
* wp wpdo site-metrics --history=eav.postmeta_rows --days=7
* wp wpdo site-metrics --format=json
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand site-metrics
*/
public function site_metrics( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$do_collect = ! empty( $assoc_args['collect'] );
// Allow dot notation (e.g. eav.postmeta_rows); sanitize without losing dots.
$history_key = isset( $assoc_args['history'] )
? preg_replace( '/[^a-z0-9._]/', '', strtolower( (string) $assoc_args['history'] ) )
: '';
$days = absint( $assoc_args['days'] ?? 30 );
$format = in_array( $assoc_args['format'] ?? 'table', array( 'table', 'json', 'csv' ), true )
? (string) ( $assoc_args['format'] ?? 'table' )
: 'table';
if ( ! class_exists( 'TMDO_Site_Metrics_Collector' ) ) {
WP_CLI::error( 'TMDO_Site_Metrics_Collector not loaded. Upgrade to v2.6.2+.' );
}
if ( $do_collect ) {
WP_CLI::log( 'Collecting site metrics now...' );
$metrics = TMDO_Site_Metrics_Collector::collect();
WP_CLI::success( sprintf( 'Collected %d metrics.', count( $metrics ) ) );
}
if ( $history_key ) {
$rows = TMDO_Site_Metrics_Collector::get_history( $history_key, $days );
if ( empty( $rows ) ) {
WP_CLI::warning( "No history for '{$history_key}' in the last {$days} days." );
return;
}
$items = array_map(
static fn( $r ) => array(
'date' => substr( (string) $r['collected_at'], 0, 10 ),
'value' => $r['value'],
),
$rows
);
WP_CLI\Utils\format_items( $format, $items, array( 'date', 'value' ) );
return;
}
$snapshot = TMDO_Site_Metrics_Collector::get_latest_snapshot();
if ( empty( $snapshot ) ) {
WP_CLI::warning( 'No metrics collected yet. Run: wp wpdo site-metrics --collect' );
return;
}
$items = array();
foreach ( $snapshot as $key => $value ) {
$items[] = array(
'metric' => $key,
'value' => number_format( (int) $value ),
'raw_value' => $value,
);
}
WP_CLI\Utils\format_items( $format, $items, array( 'metric', 'value' ) );
}
/**
* Purge old error logs and optionally archive expired listing fields to Zone D.
*
* ## OPTIONS
*
* [--days=<n>]
* : Delete logs older than N days. Default: 30.
*
* [--archive-expired]
* : Archive hot-zone fields of expired listings (hp_expired_time > 30 days ago) to Zone D.
*
* ## EXAMPLES
*
* wp wpdo cleanup
* wp wpdo cleanup --days=7
* wp wpdo cleanup --archive-expired
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments (days, archive-expired).
* @return void
*
* @subcommand cleanup
*/
public function cleanup( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$days = absint( $assoc_args['days'] ?? 30 );
$archive_expired = ! empty( $assoc_args['archive-expired'] );
WP_CLI::log( "Purging logs older than {$days} days..." );
$deleted = TMDO_Logger::purge( $days );
WP_CLI::log( "Deleted {$deleted} log entries." );
WP_CLI::log( 'Purging expired warm zone entries...' );
$warm_deleted = TMDO_Zone_Warm::purge_expired();
WP_CLI::log( "Deleted {$warm_deleted} expired warm entries." );
if ( $archive_expired ) {
// Listing archival is HivePress-specific and ships in that AddOn.
if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
WP_CLI::warning( '--archive-expired needs 2meet-data-optimizer-hivepress-addon; skipping.' );
} else {
WP_CLI::log( 'Archiving expired listing fields to Zone D...' );
$archived = TMDO_Listing_Stats::archive_expired_listings();
WP_CLI::log( "Archived {$archived} fields from expired listings." );
$stats = TMDO_Zone_Archive::stats();
WP_CLI::log( "Zone D total: {$stats['total_rows']} rows, {$stats['compressed_rows']} compressed." );
}
}
WP_CLI::success( 'Cleanup complete.' );
}
// ── REST API health check ─────────────────────────────────────────────
/**
* Health-check all /wp-json/wpdo/v1/ endpoints and report status.
*
* ## EXAMPLES
*
* wp wpdo rest-test
* wp wpdo rest-test --post-type=hp_vendor
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments (post-type).
* @return void
*
* @subcommand rest-test
*/
public function rest_test( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$post_type = sanitize_key( $assoc_args['post-type'] ?? 'hp_listing' );
WP_CLI::log( '=== WP Data Optimizer REST API Health Check ===' );
WP_CLI::log( "Base: /wp-json/wpdo/v1 | post_type: {$post_type}" );
WP_CLI::log( '' );
$pass = 0;
$fail = 0;
$items = array();
// ── T1: GET /listings ─────────────────────────────────────────────────
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', $post_type );
$req->set_param( 'per_page', 5 );
$server = rest_get_server();
$res = $server->dispatch( $req );
$status = $res->get_status();
$data = $res->get_data();
$total = $res->get_headers()['X-WP-Total'] ?? '?';
$count = is_array( $data ) ? count( $data ) : 0;
if ( 200 === $status ) {
WP_CLI::log( "[OK] GET /listings → HTTP {$status}, items={$count}, X-WP-Total={$total}" );
++$pass;
// Pick a test post ID from results.
if ( ! empty( $data[0]['id'] ) ) {
$items[] = (int) $data[0]['id'];
}
} else {
WP_CLI::warning( "[FAIL] GET /listings → HTTP {$status}" );
++$fail;
}
// ── T2: GET /listings with filter ────────────────────────────────────
$registry = TMDO_Schema_Registry::instance();
$cols = array_keys( $registry->get_hot_columns( $post_type ) );
if ( $cols ) {
$req2 = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req2->set_param( 'post_type', $post_type );
$req2->set_param( 'per_page', 3 );
// No filter value — just verify the query doesn't error.
$res2 = $server->dispatch( $req2 );
$status2 = $res2->get_status();
if ( 200 === $status2 ) {
WP_CLI::log( "[OK] GET /listings?per_page=3 → HTTP {$status2}" );
++$pass;
} else {
WP_CLI::warning( "[FAIL] GET /listings?per_page=3 → HTTP {$status2}" );
++$fail;
}
}
// ── T3: GET /listings/{id} ────────────────────────────────────────────
$test_id = $items[0] ?? 0;
if ( $test_id ) {
$req3 = new WP_REST_Request( 'GET', "/wpdo/v1/listings/{$test_id}" );
$req3->set_param( 'id', $test_id );
$res3 = $server->dispatch( $req3 );
$status3 = $res3->get_status();
$d3 = $res3->get_data();
$keys = is_array( $d3 ) ? implode( ', ', array_keys( $d3 ) ) : '?';
if ( 200 === $status3 ) {
WP_CLI::log( "[OK] GET /listings/{$test_id} → HTTP {$status3}, keys: {$keys}" );
++$pass;
} else {
WP_CLI::warning( "[FAIL] GET /listings/{$test_id} → HTTP {$status3}" );
++$fail;
}
} else {
WP_CLI::log( "[SKIP] GET /listings/{id} — no posts found for post_type={$post_type}" );
}
// ── T4: GET /stats/{id} ───────────────────────────────────────────────
if ( $test_id ) {
$req4 = new WP_REST_Request( 'GET', "/wpdo/v1/stats/{$test_id}" );
$req4->set_param( 'id', $test_id );
$res4 = $server->dispatch( $req4 );
$status4 = $res4->get_status();
$d4 = $res4->get_data();
$views = $d4['view_count'] ?? '?';
if ( 200 === $status4 ) {
WP_CLI::log( "[OK] GET /stats/{$test_id} → HTTP {$status4}, view_count={$views}" );
++$pass;
} else {
WP_CLI::warning( "[FAIL] GET /stats/{$test_id} → HTTP {$status4}" );
++$fail;
}
} else {
WP_CLI::log( '[SKIP] GET /stats/{id} — no posts found' );
}
// ── T5: GET /status ───────────────────────────────────────────────────
// Temporarily grant manage_options for CLI context.
add_filter(
'user_has_cap',
function ( $caps ) {
$caps['manage_options'] = true;
return $caps;
}
);
$req5 = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
$res5 = $server->dispatch( $req5 );
$status5 = $res5->get_status();
$d5 = $res5->get_data();
$version = $d5['version'] ?? '?';
$engine = $d5['engine'] ?? '?';
if ( 200 === $status5 ) {
WP_CLI::log( "[OK] GET /status → HTTP {$status5}, version={$version}, engine={$engine}" );
++$pass;
} else {
WP_CLI::warning( "[FAIL] GET /status → HTTP {$status5}" );
++$fail;
}
// ── Summary ───────────────────────────────────────────────────────────
WP_CLI::log( '' );
$total_tests = $pass + $fail;
if ( 0 === $fail ) {
WP_CLI::success( "All {$total_tests} REST tests passed." );
} else {
WP_CLI::error( "{$fail}/{$total_tests} REST tests failed.", false );
}
}
/**
* Add covering indexes to all Zone A (hot) tables.
*
* Analyses each hot table's column types and creates optimal covering indexes:
* - DECIMAL columns → single-column idx for range queries + ORDER BY
* - TINYINT + DECIMAL → compound (flag, price) for filtered sorts
* - BIGINT _time + DECIMAL → compound (expiry, price) for active listing queries
* - TINYINT + matching BIGINT _time → compound (flag, time) for featured ordering
*
* Safe to run on existing installations — skips already-present indexes.
* No-op on SQLite.
*
* ## OPTIONS
*
* [<post_type>]
* : Limit to a specific post type (e.g. hp_listing). Defaults to all hot post types.
*
* ## EXAMPLES
*
* wp wpdo add-indexes
* wp wpdo add-indexes hp_listing
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand add-indexes
*/
public function add_indexes( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
if ( TMDO_IS_SQLITE ) {
WP_CLI::warning( 'Covering indexes are MySQL-only. SQLite is not supported.' );
return;
}
$registry = TMDO_Schema_Registry::instance();
$post_types = $registry->get_hot_post_types();
// Optional filter by post_type argument.
if ( ! empty( $args[0] ) ) {
$filter = sanitize_key( $args[0] );
$post_types = array_filter( $post_types, fn( $pt ) => sanitize_key( $pt ) === $filter );
if ( empty( $post_types ) ) {
WP_CLI::error( "No hot zone table registered for post_type '{$args[0]}'." );
return;
}
}
foreach ( $post_types as $post_type ) {
$columns = $registry->get_hot_columns( $post_type );
if ( empty( $columns ) ) {
WP_CLI::log( "[SKIP] {$post_type} — no hot columns registered." );
continue;
}
WP_CLI::log( "Adding covering indexes for {$post_type}..." );
TMDO_Installer::add_covering_indexes( $post_type, $columns );
WP_CLI::log( ' Done.' );
}
WP_CLI::success( 'Covering indexes applied.' );
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Reject table names that are not plain identifiers.
*
* Guards raw SQL that cannot use $wpdb->prepare() for table identifiers
* (SHOW COLUMNS, PRAGMA, COUNT(*) probes). All callers derive $table_name
* from TMDO_DB::table() or Custom_Table_Registry — developer-controlled,
* not from HTTP input — but this check prevents breakage if that ever changes.
*
* @param string $table_name Fully-qualified table name to validate.
*/
private static function assert_safe_table_name( string $table_name ): void {
if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table_name ) ) {
WP_CLI::error( "Unsafe table name rejected: '{$table_name}'" );
}
}
/**
* Format the HPCT status string for display.
*
* @param array $compat Compatibility check result array.
* @return string Human-readable HPCT status.
*/
private static function format_hpct_status( array $compat ): string {
if ( $compat['hpct_active'] && $compat['hpct_imported'] ) {
return 'Active (Imported)';
}
if ( $compat['hpct_active'] ) {
return 'Active (Not imported — run `wp wpdo import-hpct`)';
}
return 'Not found';
}
/**
* Create the appropriate migration instance for a module name.
*
* @param string $module Module identifier (e.g., hot_hp_listing, warm, archive).
* @return TMDO_Migration_Base|null Migration instance, or null if module is unknown.
*/
private static function get_migration_instance( string $module ): ?TMDO_Migration_Base {
// Zone migrations.
if ( str_starts_with( $module, 'hot_' ) ) {
$post_type = substr( $module, 4 );
return new TMDO_Hot_Migration( $post_type );
}
if ( str_starts_with( $module, 'cold_' ) ) {
$post_type = substr( $module, 5 );
return new TMDO_Cold_Migration( $post_type );
}
if ( 'warm' === $module ) {
return new TMDO_Warm_Migration();
}
if ( 'archive' === $module ) {
return new TMDO_Archive_Migration();
}
return null;
}
/**
* Capture a site-wide health snapshot. Writes JSON to the output dir
* (default: wp-data-optimizer/docs/snapshots/) and prints a summary.
*
* Re-running monthly produces a series of time-stamped snapshots that can
* be diffed to detect regressions (DB bloat, autoload pollution, missing
* registrations).
*
* ## OPTIONS
*
* [--out=<path>]
* : Output directory. Defaults to plugin's docs/snapshots/.
*
* [--quiet]
* : Suppress human-readable summary; only print the JSON path.
*
* [--json-only]
* : Emit raw JSON to stdout (CI-friendly). Suppresses human prose entirely.
*
* [--diff-since=<days>]
* : Compare against snapshot from N days ago instead of the previous one.
*
* ## EXAMPLES
*
* wp wpdo health-snapshot
* wp wpdo health-snapshot --out=/tmp
* wp wpdo health-snapshot --json-only > today.json
* wp wpdo health-snapshot --diff-since=30
*
* @param array $args Positional args (unused).
* @param array $assoc_args Flag args.
* @return void
*
* @subcommand health-snapshot
*/
public function health_snapshot( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
global $wpdb;
$quiet = ! empty( $assoc_args['quiet'] );
$json_only = ! empty( $assoc_args['json-only'] );
$diff_since = isset( $assoc_args['diff-since'] ) ? max( 1, (int) $assoc_args['diff-since'] ) : 0;
// v2.1.2 fix: default to wp-content/uploads/wpdo-snapshots/ — plugin dir
// is read-only on hardened production hosts. Override with --out.
$default_outdir = ( function_exists( 'wp_upload_dir' ) ? ( wp_upload_dir()['basedir'] ?? sys_get_temp_dir() ) : sys_get_temp_dir() ) . '/wpdo-snapshots';
$outdir = $assoc_args['out'] ?? $default_outdir;
if ( ! is_dir( $outdir ) ) {
wp_mkdir_p( $outdir );
}
// Trigger field registration so we capture the full picture.
do_action( 'wpdo_register_fields', TMDO_Schema_Registry::instance() );
// 1. DB size + top tables (v2.1.2: SQLite-aware).
if ( defined( 'TMDO_IS_SQLITE' ) && TMDO_IS_SQLITE ) {
// SQLite has no information_schema; size is the .sqlite file size.
$db_file = defined( 'DB_FILE' ) ? DB_FILE : ( WP_CONTENT_DIR . '/database/.ht.sqlite' );
$db_size = file_exists( $db_file ) ? (int) filesize( $db_file ) : 0;
// Top tables via sqlite_master + per-table COUNT (slower but correct).
$tables = $wpdb->get_col( "SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE '" . $wpdb->prefix . "2m%' OR name LIKE '" . $wpdb->prefix . "wpdo%' OR name LIKE '" . $wpdb->prefix . "tmqi%')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
$top_tables = array();
foreach ( (array) $tables as $t ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
$top_tables[] = array(
'TABLE_NAME' => $t,
'TABLE_ROWS' => $rows,
'DATA_LENGTH' => 0,
);
}
usort( $top_tables, static fn( $a, $b ) => $b['TABLE_ROWS'] <=> $a['TABLE_ROWS'] );
$top_tables = array_slice( $top_tables, 0, 10 );
} else {
$db_size = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
'SELECT SUM(DATA_LENGTH + INDEX_LENGTH) FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s',
DB_NAME
)
);
$top_tables = $wpdb->get_results(
$wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
'SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH FROM information_schema.TABLES
WHERE TABLE_SCHEMA = %s
AND (TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s)
ORDER BY DATA_LENGTH DESC LIMIT 10',
DB_NAME,
$wpdb->prefix . '2m%',
$wpdb->prefix . 'wpdo%',
$wpdb->prefix . 'tmqi%'
),
ARRAY_A
);
}
// 2. autoload pollution.
$autoload_total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->options} WHERE autoload IN ('yes','on')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$autoload_size = (int) $wpdb->get_var( "SELECT SUM(LENGTH(option_value)) FROM {$wpdb->options} WHERE autoload IN ('yes','on')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
// 3. WPDO custom_table coverage.
$registry = TMDO_Custom_Table_Registry::instance();
$ref = new ReflectionClass( $registry );
$prop = $ref->getProperty( 'tables' );
$prop->setAccessible( true );
$all_tables = $prop->getValue( $registry );
$by_provider = array();
foreach ( $all_tables as $t ) {
$p = $t['provider'] ?? 'unknown';
$by_provider[ $p ] = ( $by_provider[ $p ] ?? 0 ) + 1;
}
// 4. Schema_Registry hot fields.
$schema = TMDO_Schema_Registry::instance();
$pref = new ReflectionClass( $schema );
$pp = $pref->getProperty( 'fields' );
$pp->setAccessible( true );
$fields = $pp->getValue( $schema );
$fields_by_provider = array();
foreach ( $fields as $f ) {
$p = $f['provider'] ?? 'unknown';
$fields_by_provider[ $p ] = ( $fields_by_provider[ $p ] ?? 0 ) + 1;
}
// 5. wpdo_errors row count.
$errors_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wpdo_errors" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$snapshot = array(
'taken_at' => current_time( 'mysql' ),
'wpdo_version' => defined( 'TMDO_VERSION' ) ? TMDO_VERSION : 'unknown',
'db' => array(
'total_size_bytes' => $db_size,
'total_size_mb' => round( $db_size / 1048576, 2 ),
'top_tables' => $top_tables ?: array(),
),
'autoload' => array(
'count' => $autoload_total,
'size_kb' => round( ( $autoload_size ?: 0 ) / 1024, 1 ),
),
'custom_tables' => array(
'total' => count( $all_tables ),
'by_provider' => $by_provider,
),
'schema_fields' => array(
'total' => count( $fields ),
'by_provider' => $fields_by_provider,
),
'wpdo_errors_count' => $errors_count,
);
$filename = sprintf( '%s/health-%s.json', rtrim( $outdir, '/' ), gmdate( 'Y-m-d-His' ) );
$ok = file_put_contents(
$filename,
wp_json_encode( $snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES )
);
if ( false === $ok ) {
WP_CLI::error( "Failed to write snapshot to {$filename}" );
}
// v2.1.2 fix: prune snapshots > 24 to bound directory growth (monthly cron
// over years would otherwise fill disk + slow glob+sort).
$all_snaps = glob( rtrim( $outdir, '/' ) . '/health-*.json' );
if ( $all_snaps && count( $all_snaps ) > 24 ) {
sort( $all_snaps );
foreach ( array_slice( $all_snaps, 0, count( $all_snaps ) - 24 ) as $old ) {
@unlink( $old ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup
}
}
// --json-only: emit the raw JSON to stdout and exit silently.
if ( $json_only ) {
WP_CLI::log( wp_json_encode( $snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) );
return;
}
if ( $quiet ) {
WP_CLI::log( $filename );
return;
}
WP_CLI::log( '═══ WPDO Health Snapshot ═══' );
WP_CLI::log( ' Taken: ' . $snapshot['taken_at'] );
WP_CLI::log( ' WPDO version: ' . $snapshot['wpdo_version'] );
WP_CLI::log( ' DB total: ' . $snapshot['db']['total_size_mb'] . ' MB' );
WP_CLI::log( ' Custom tables registered: ' . $snapshot['custom_tables']['total'] );
WP_CLI::log( ' Schema fields registered: ' . $snapshot['schema_fields']['total'] );
WP_CLI::log( ' Autoload entries: ' . $snapshot['autoload']['count'] . ' (' . $snapshot['autoload']['size_kb'] . ' KB)' );
WP_CLI::log( ' wp_wpdo_errors rows: ' . $snapshot['wpdo_errors_count'] );
WP_CLI::log( '' );
WP_CLI::log( "Snapshot saved: {$filename}" );
// Prefer --diff-since=N (find snapshot from ~N days ago); fallback to
// previous snapshot in the directory.
$snapshots = glob( rtrim( $outdir, '/' ) . '/health-*.json' );
$prev_path = null;
if ( $snapshots && count( $snapshots ) >= 2 ) {
sort( $snapshots );
if ( $diff_since > 0 ) {
// Find the snapshot closest to (now - $diff_since days).
$target_ts = time() - $diff_since * DAY_IN_SECONDS;
$best_diff = PHP_INT_MAX;
foreach ( $snapshots as $candidate ) {
if ( preg_match( '/health-(\d{4}-\d{2}-\d{2})/', basename( $candidate ), $m ) ) {
$cand_ts = strtotime( $m[1] );
$diff = abs( $cand_ts - $target_ts );
if ( $diff < $best_diff && $candidate !== $filename ) {
$best_diff = $diff;
$prev_path = $candidate;
}
}
}
} else {
$prev_path = $snapshots[ count( $snapshots ) - 2 ]; // second-latest.
}
}
if ( $prev_path ) {
$prev = json_decode( (string) file_get_contents( $prev_path ), true );
if ( is_array( $prev ) ) {
WP_CLI::log( '' );
WP_CLI::log( 'Δ vs ' . basename( $prev_path ) . ':' );
WP_CLI::log( sprintf( ' DB: %+0.2f MB', $snapshot['db']['total_size_mb'] - ( $prev['db']['total_size_mb'] ?? 0 ) ) );
WP_CLI::log( sprintf( ' Tables: %+d', $snapshot['custom_tables']['total'] - ( $prev['custom_tables']['total'] ?? 0 ) ) );
WP_CLI::log( sprintf( ' Fields: %+d', $snapshot['schema_fields']['total'] - ( $prev['schema_fields']['total'] ?? 0 ) ) );
WP_CLI::log( sprintf( ' Errors: %+d', $snapshot['wpdo_errors_count'] - ( $prev['wpdo_errors_count'] ?? 0 ) ) );
WP_CLI::log( sprintf( ' Autoload: %+d entries', $snapshot['autoload']['count'] - ( $prev['autoload']['count'] ?? 0 ) ) );
// Threshold breach detection — write to last_snapshot_alert option.
$db_growth_pct = ( $prev['db']['total_size_mb'] ?? 0 ) > 0
? ( ( $snapshot['db']['total_size_mb'] - $prev['db']['total_size_mb'] ) / $prev['db']['total_size_mb'] ) * 100
: 0;
if ( $db_growth_pct > 10 ) {
update_option(
'wpdo_health_alert',
sprintf(
'DB grew %.1f%% (%.2f MB → %.2f MB) since %s',
$db_growth_pct,
(float) ( $prev['db']['total_size_mb'] ?? 0 ),
(float) $snapshot['db']['total_size_mb'],
basename( $prev_path )
),
false
);
WP_CLI::warning( sprintf( 'DB grew %.1f%% — admin notice will fire.', $db_growth_pct ) );
} else {
delete_option( 'wpdo_health_alert' );
}
}
}
WP_CLI::success( 'Health snapshot complete.' );
}
/**
* Generate scaffolding for a new partner plugin's WPDO integration class.
*
* Output: prints a ready-to-paste integration class to stdout (or --out file).
* Saves ~30 minutes per new plugin onboarding by following Tier 4 cookbook pattern.
*
* ## OPTIONS
*
* <slug>
* : Plugin slug (e.g. 2meet-newplugin). Used to derive class name + table prefix.
*
* [--prefix=<prefix>]
* : Table prefix (e.g. 2mn for 2meet-newplugin). Defaults to first 3 chars of slug.
*
* [--class=<class>]
* : Class name (default: derived from slug, e.g. `TMEETIC_Newplugin_WPDO`).
*
* [--out=<path>]
* : Output file. Defaults to stdout.
*
* ## EXAMPLES
*
* wp wpdo register-stub 2meet-newplugin
* wp wpdo register-stub 2meet-newplugin --prefix=2mn --out=/tmp/stub.php
*
* @param array $args Positional args.
* @param array $assoc_args Flag args.
* @return void
*
* @subcommand register-stub
*/
public function register_stub( $args, $assoc_args ): void {
$slug = isset( $args[0] ) ? sanitize_title( (string) $args[0] ) : '';
if ( '' === $slug ) {
WP_CLI::error( 'Usage: wp wpdo register-stub <plugin-slug>' );
}
$prefix = isset( $assoc_args['prefix'] )
? sanitize_key( (string) $assoc_args['prefix'] )
: substr( preg_replace( '/[^a-z]/', '', strtolower( str_replace( array( '2meet-', '-' ), '', $slug ) ) ), 0, 3 );
$class = isset( $assoc_args['class'] )
? (string) $assoc_args['class']
: 'TMEETIC_' . str_replace( ' ', '_', ucwords( str_replace( array( '2meet-', '-' ), array( '', ' ' ), $slug ) ) ) . '_WPDO';
$stub = self::render_stub( $slug, $prefix, $class );
if ( ! empty( $assoc_args['out'] ) ) {
$ok = file_put_contents( $assoc_args['out'], $stub );
if ( false === $ok ) {
WP_CLI::error( "Failed to write {$assoc_args['out']}" );
}
WP_CLI::success( "Stub written to {$assoc_args['out']}" );
return;
}
WP_CLI::log( $stub );
}
/**
* Render the scaffolding template.
*
* @param string $slug Plugin slug.
* @param string $prefix Table prefix.
* @param string $class_name Integration class name.
* @return string PHP source code.
*/
private static function render_stub( string $slug, string $prefix, string $class_name ): string {
$year = gmdate( 'Y' );
return <<<PHP
<?php
/**
* {$slug} ↔ wp-data-optimizer integration.
*
* Generated by: wp wpdo register-stub {$slug}
* Pattern: Tier 4 (greenfield plugin) per
* wp-data-optimizer/docs/ENTITY_ADAPTER_COOKBOOK.md
*
* @package {$slug}
* @since {$year}-01-01
*/
if ( ! defined( 'ABSPATH' ) ) {
\texit;
}
/**
* WPDO integration registrar for {$slug}.
*/
final class {$class_name} {
\t/**
\t * Custom tables this plugin owns. Suffix only (no wp_ prefix).
\t *
\t * @var array<string, array{post_type_link: ?string, description: string}>
\t */
\tprivate const TABLES = array(
\t\t'{$prefix}_example_one' => array( 'post_type_link' => null, 'description' => '範例表 1' ),
\t\t'{$prefix}_example_two' => array( 'post_type_link' => null, 'description' => '範例表 2' ),
\t);
\tpublic static function register(): void {
\t\tadd_action( 'wpdo_register_custom_tables', array( __CLASS__, 'register_tables' ) );
\t}
\tpublic static function register_tables( \$registry = null ): void {
\t\tif ( ! class_exists( 'TMDO_Custom_Table_Registry' ) ) {
\t\t\treturn;
\t\t}
\t\t\$registry = \$registry ?: TMDO_Custom_Table_Registry::instance();
\t\tforeach ( self::TABLES as \$name => \$meta ) {
\t\t\t\$registry->register(
\t\t\t\t'{$slug}',
\t\t\t\tarray(
\t\t\t\t\t'table_name' => \$name,
\t\t\t\t\t'primary_key' => 'id',
\t\t\t\t\t'post_type_link' => \$meta['post_type_link'],
\t\t\t\t\t'doctor_callback' => array( __CLASS__, 'doctor_check' ),
\t\t\t\t\t'description' => \$meta['description'],
\t\t\t\t)
\t\t\t);
\t\t}
\t}
\tpublic static function doctor_check( string \$table_suffix ): array {
\t\tglobal \$wpdb;
\t\t\$full = \$wpdb->prefix . \$table_suffix;
\t\t\$exists = (bool) \$wpdb->get_var( \$wpdb->prepare( 'SHOW TABLES LIKE %s', \$full ) );
\t\tif ( ! \$exists ) {
\t\t\treturn array( 'ok' => false, 'message' => "{\$full} 不存在" );
\t\t}
\t\t\$count = (int) \$wpdb->get_var( "SELECT COUNT(*) FROM `{\$full}`" );
\t\treturn array( 'ok' => true, 'message' => "{\$full}: {\$count} rows" );
\t}
}
PHP;
}
}