*/ 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(); 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. // 傳入 table_suffix + rows + full_name 作為 context;多餘參數對 callable 無害。 $cb = $cfg['doctor_callback'] ?? null; if ( is_callable( $cb ) ) { try { $result = call_user_func( $cb, $cfg['table_name'], $rows, $full_name ); $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)" ); } } } } // 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=] * : Post type to analyze. If omitted, analyzes hp_listing. * * [--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 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 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 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 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 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 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 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 * * [] * : Optional module name to benchmark. * * [--samples=] * : 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_Listing_Stats::VIEW_KEY, '1', DAY_IN_SECONDS ); } $start = microtime( true ); foreach ( $warm_posts as $pid ) { TMDO_Zone_Warm::get( (int) $pid, TMDO_Listing_Stats::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; } $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=] * : Show 30-day daily history for a specific metric key (e.g. eav.postmeta_rows). * * [--days=] * : Number of days to show in history mode. Default: 30. * * [--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=] * : 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 ) { 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 * * [] * : 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 ─────────────────────────────────────────────────── /** * 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=] * : 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=] * : 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 * * * : Plugin slug (e.g. 2meet-newplugin). Used to derive class name + table prefix. * * [--prefix=] * : Table prefix (e.g. 2mn for 2meet-newplugin). Defaults to first 3 chars of slug. * * [--class=] * : Class name (default: derived from slug, e.g. `TMEETIC_Newplugin_WPDO`). * * [--out=] * : 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 ' ); } $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 << \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; } }