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
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。
1834 lines
62 KiB
PHP
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;
|
|
}
|
|
}
|