a8190a6828
E4:38 個子指令補 'wp tmdo' 註冊(原本只有 'wp wpdo')
cli-v2 15、cli-post 8、cli-member 7、cli-term-comment 8。
CLAUDE.md 宣稱 tmdo 為主命名空間,但 wp tmdo bridge-status /
snapshot create 等先前根本不存在。
E6:MENU_SLUG 由 '2meet-data-optimizer' 改回 'wp-data-optimizer'。
B 內部有 10+ 處連結(help-tabs、setup-wizard ×5、conflict-monitor、
export、3 個 template)硬編碼舊 slug,與註冊值不符 → 這些連結目前
全部 404。既然本外掛已沿用 wpdo_ 的表/option/cron/hook/CLI 命名空間,
選單 slug 一併回到同一套最連貫,A 退休後亦無衝突。
E7:13 處殘留的 'uae' text domain 改為 '2meet-data-optimizer'
(conflict-detector 3、mode-manager 9、entity-migration-engine 1;
另一處 'uae' 是陣列 key 非 text domain,保留)。
unit 451 / integration 398 GREEN
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
853 lines
27 KiB
PHP
853 lines
27 KiB
PHP
<?php
|
|
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
|
|
/**
|
|
* TMDO_CLI_Post — Post entity CLI subcommands (v2.9.0+).
|
|
*
|
|
* Adds the following subcommands under `wp wpdo`:
|
|
*
|
|
* wp wpdo postmeta-cleanup — Clean wp_postmeta garbage (transients,
|
|
* _wp_old_date, stale _edit_lock).
|
|
*
|
|
* Mirrors TMDO_CLI_Member's design: thin wrapper around core class
|
|
* (TMDO_Postmeta_Cleaner). Future post-entity subcommands (post-audit,
|
|
* post-backfill, post-cutover) will live in this same file.
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
* @since 2.9.0
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.Commenting.DocComment.ShortNotCapital -- WP_CLI callbacks accept ($args, $assoc_args) by contract; many subcommands only need one.
|
|
|
|
if ( ! class_exists( 'WP_CLI' ) ) {
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Post entity CLI subcommands. Loaded only in WP_CLI context.
|
|
*/
|
|
class TMDO_CLI_Post {
|
|
|
|
// ── postmeta-cleanup ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Clean wp_postmeta garbage rows (transients, _wp_old_date, stale _edit_lock).
|
|
*
|
|
* Phase 0 of v2.9.0 Post Entity migration. Runs before any Entity Bridge
|
|
* work so subsequent ratio measurements reflect real data, not garbage.
|
|
*
|
|
* SAFETY: by default this command refuses to run. Pass --dry-run to
|
|
* preview row counts without deleting, or --confirm to actually delete.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* [--target=<target>]
|
|
* : Which garbage class to address. Default: all.
|
|
* ---
|
|
* default: all
|
|
* options:
|
|
* - all
|
|
* - transients
|
|
* - wp_old_date
|
|
* - edit_locks
|
|
* ---
|
|
*
|
|
* [--dry-run]
|
|
* : Show row counts without deleting.
|
|
*
|
|
* [--confirm]
|
|
* : Required to actually DELETE rows. Mutually exclusive with --dry-run.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp wpdo postmeta-cleanup --dry-run
|
|
* wp wpdo postmeta-cleanup --target=transients --dry-run
|
|
* wp wpdo postmeta-cleanup --confirm
|
|
* wp wpdo postmeta-cleanup --target=edit_locks --confirm
|
|
*
|
|
* @param array $args Positional arguments (unused).
|
|
* @param array $assoc_args Named arguments.
|
|
*/
|
|
public function postmeta_cleanup( $args, $assoc_args ): void {
|
|
$target = (string) ( $assoc_args['target'] ?? TMDO_Postmeta_Cleaner::TARGET_ALL );
|
|
$dry_run = isset( $assoc_args['dry-run'] );
|
|
$confirm = isset( $assoc_args['confirm'] );
|
|
|
|
if ( ! in_array( $target, TMDO_Postmeta_Cleaner::VALID_TARGETS, true ) ) {
|
|
WP_CLI::error(
|
|
'Invalid --target. Choose: ' . implode( ', ', TMDO_Postmeta_Cleaner::VALID_TARGETS )
|
|
);
|
|
}
|
|
|
|
if ( $dry_run && $confirm ) {
|
|
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
|
|
}
|
|
|
|
// Default safety: refuse to run without an explicit choice.
|
|
if ( ! $dry_run && ! $confirm ) {
|
|
WP_CLI::error(
|
|
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" .
|
|
'Example: wp wpdo postmeta-cleanup --dry-run'
|
|
);
|
|
}
|
|
|
|
if ( $dry_run ) {
|
|
$counts = TMDO_Postmeta_Cleaner::count_garbage( $target );
|
|
self::render_table( $counts, 'would delete' );
|
|
WP_CLI::success(
|
|
sprintf( '[dry-run] %d row(s) would be deleted. Re-run with --confirm to apply.', $counts['total'] )
|
|
);
|
|
return;
|
|
}
|
|
|
|
// $confirm path.
|
|
$deleted = TMDO_Postmeta_Cleaner::delete_garbage( $target );
|
|
self::render_table( $deleted, 'deleted' );
|
|
|
|
if ( class_exists( 'TMDO_Logger' ) ) {
|
|
TMDO_Logger::info(
|
|
'postmeta_cleanup',
|
|
array(
|
|
'target' => $target,
|
|
'transients' => $deleted['transients'],
|
|
'wp_old_date' => $deleted['wp_old_date'],
|
|
'edit_locks' => $deleted['edit_locks'],
|
|
'total' => $deleted['total'],
|
|
)
|
|
);
|
|
}
|
|
|
|
WP_CLI::success( sprintf( 'Deleted %d row(s) from wp_postmeta.', $deleted['total'] ) );
|
|
}
|
|
|
|
/**
|
|
* Render a table of bucket → count.
|
|
*
|
|
* @param array $counts Output from TMDO_Postmeta_Cleaner::count_garbage() / delete_garbage().
|
|
* @param string $verb Column header verb ('would delete' / 'deleted').
|
|
*/
|
|
private static function render_table( array $counts, string $verb ): void {
|
|
$rows = array(
|
|
array(
|
|
'bucket' => 'transients',
|
|
$verb => $counts['transients'],
|
|
'rule' => '_transient_% OR _transient_timeout_%',
|
|
),
|
|
array(
|
|
'bucket' => 'wp_old_date',
|
|
$verb => $counts['wp_old_date'],
|
|
'rule' => "meta_key = '_wp_old_date'",
|
|
),
|
|
array(
|
|
'bucket' => 'edit_locks',
|
|
$verb => $counts['edit_locks'],
|
|
'rule' => '_edit_lock older than 24h',
|
|
),
|
|
array(
|
|
'bucket' => 'TOTAL',
|
|
$verb => $counts['total'],
|
|
'rule' => '',
|
|
),
|
|
);
|
|
WP_CLI\Utils\format_items( 'table', $rows, array( 'bucket', $verb, 'rule' ) );
|
|
}
|
|
|
|
// ── post-diagnose (v2.9.3) ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Show wp_posts:wp_postmeta ratio + per-group EAV residue / flat row count.
|
|
*
|
|
* Read-only command. Mirrors `wp wpdo member-audit` for the post entity.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp wpdo post-diagnose
|
|
*
|
|
* @param array $args Positional arguments (unused).
|
|
* @param array $assoc_args Named arguments (unused).
|
|
*/
|
|
public function post_diagnose( $args, $assoc_args ): void {
|
|
$result = TMDO_Post_Migration::diagnose();
|
|
|
|
WP_CLI::log( sprintf( 'Posts: %s', number_format_i18n( $result['posts'] ) ) );
|
|
WP_CLI::log( sprintf( 'Postmeta: %s', number_format_i18n( $result['postmeta'] ) ) );
|
|
WP_CLI::log( sprintf( 'Ratio: 1:%s', $result['ratio'] ) );
|
|
WP_CLI::log( sprintf( 'Mode: %s', $result['mode'] ) );
|
|
WP_CLI::log( '' );
|
|
|
|
$rows = array();
|
|
foreach ( $result['groups'] as $name => $g ) {
|
|
$rows[] = array(
|
|
'group' => $name,
|
|
'post_type' => $g['post_type'] ?: '(any)',
|
|
'keys' => count( $g['keys'] ),
|
|
'eav_rows' => $g['eav_rows'],
|
|
'flat_rows' => $g['flat_rows'],
|
|
);
|
|
}
|
|
WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'post_type', 'keys', 'eav_rows', 'flat_rows' ) );
|
|
}
|
|
|
|
// ── post-migrate-group (v2.9.3) ───────────────────────────────────────────
|
|
|
|
/**
|
|
* Backfill one post entity group from wp_postmeta into its flat table
|
|
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE).
|
|
*
|
|
* Idempotent — safe to re-run. Skips JSON-typed fields (handled by the
|
|
* row-by-row backfill phase in v2.9.4+).
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* --group=<group>
|
|
* : Entity group name. Required.
|
|
* ---
|
|
* options:
|
|
* - wp_core
|
|
* - attachment
|
|
* - wc_product
|
|
* - hp_listing_core
|
|
* - hp_request_core
|
|
* - hp_vendor_core
|
|
* - nav_menu_item
|
|
* ---
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp wpdo post-migrate-group --group=wc_product
|
|
* wp wpdo post-migrate-group --group=hp_listing_core
|
|
*
|
|
* @param array $args Positional arguments (unused).
|
|
* @param array $assoc_args Named arguments.
|
|
*/
|
|
public function post_migrate_group( $args, $assoc_args ): void {
|
|
$group = (string) ( $assoc_args['group'] ?? '' );
|
|
if ( '' === $group ) {
|
|
WP_CLI::error( 'Missing required --group=<name>.' );
|
|
}
|
|
|
|
try {
|
|
$result = TMDO_Post_Migration::backfill_group( $group );
|
|
} catch ( \Throwable $e ) {
|
|
WP_CLI::error( $e->getMessage() );
|
|
}
|
|
|
|
if ( class_exists( 'TMDO_Logger' ) ) {
|
|
TMDO_Logger::info(
|
|
'post_migrate_group',
|
|
array(
|
|
'group' => $result['group'],
|
|
'post_type' => $result['post_type'],
|
|
'migrated' => $result['migrated'],
|
|
)
|
|
);
|
|
}
|
|
|
|
WP_CLI::success(
|
|
sprintf(
|
|
'Backfilled %s (post_type=%s): %d post(s) migrated.',
|
|
$result['group'],
|
|
$result['post_type'] ?: '(any)',
|
|
$result['migrated']
|
|
)
|
|
);
|
|
}
|
|
|
|
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* DELETE managed-key wp_postmeta rows after cutover. Requires post mode
|
|
* to be aeav_only (the flat table is the authoritative source).
|
|
*
|
|
* SAFETY: requires --confirm.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* [--dry-run]
|
|
* : Preview row count only.
|
|
*
|
|
* [--confirm]
|
|
* : Required to actually DELETE rows.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp wpdo post-cleanup --dry-run
|
|
* wp wpdo post-cleanup --confirm
|
|
*
|
|
* @param array $args Positional arguments (unused).
|
|
* @param array $assoc_args Named arguments.
|
|
*/
|
|
public function post_cleanup( $args, $assoc_args ): void {
|
|
$dry_run = isset( $assoc_args['dry-run'] );
|
|
$confirm = isset( $assoc_args['confirm'] );
|
|
|
|
if ( $dry_run && $confirm ) {
|
|
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
|
|
}
|
|
if ( ! $dry_run && ! $confirm ) {
|
|
WP_CLI::error(
|
|
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" .
|
|
'Example: wp wpdo post-cleanup --dry-run'
|
|
);
|
|
}
|
|
|
|
$diagnose = TMDO_Post_Migration::diagnose();
|
|
$keys = TMDO_Post_Migration::get_managed_keys();
|
|
|
|
global $wpdb;
|
|
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
|
$sql = "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
|
$candidate = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$keys ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
|
|
|
|
if ( $dry_run ) {
|
|
WP_CLI::log( sprintf( 'Mode: %s', $diagnose['mode'] ) );
|
|
WP_CLI::log( sprintf( 'Managed keys: %d', count( $keys ) ) );
|
|
WP_CLI::success(
|
|
sprintf(
|
|
'[dry-run] %d wp_postmeta row(s) would be deleted. Re-run with --confirm.',
|
|
$candidate
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
// $confirm path — TMDO_Post_Migration::cleanup() enforces mode=aeav_only.
|
|
try {
|
|
$result = TMDO_Post_Migration::cleanup();
|
|
} catch ( \Throwable $e ) {
|
|
WP_CLI::error( $e->getMessage() );
|
|
}
|
|
|
|
if ( class_exists( 'TMDO_Logger' ) ) {
|
|
TMDO_Logger::info(
|
|
'post_cleanup',
|
|
array(
|
|
'deleted' => $result['deleted'],
|
|
)
|
|
);
|
|
}
|
|
|
|
WP_CLI::success( sprintf( 'Deleted %d wp_postmeta row(s).', $result['deleted'] ) );
|
|
}
|
|
|
|
// ── cleanup-hp-transients (v2.11.5) ───────────────────────────────────────
|
|
|
|
/**
|
|
* Purge legacy `_transient_hp_*` rows from wp_postmeta.
|
|
*
|
|
* HivePress (`hivepress/includes/components/class-cache.php`) writes
|
|
* per-post TTL caches as `_transient_<name>` / `_transient_timeout_<name>`
|
|
* postmeta rows. v2.11.5 ships `TMDO_Hivepress_Transient_Filter` to
|
|
* intercept new writes and reroute them to wp_options. This command does
|
|
* the one-time historical cleanup — DELETE-ing all such existing rows
|
|
* from wp_postmeta. After cleanup, HivePress re-fetches on demand.
|
|
*
|
|
* Safe to run with the filter enabled; the filter prevents new postmeta
|
|
* writes from re-bloating the table.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* [--dry-run]
|
|
* : Preview row count only.
|
|
*
|
|
* [--confirm]
|
|
* : Required to actually DELETE rows.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp wpdo cleanup-hp-transients --dry-run
|
|
* wp wpdo cleanup-hp-transients --confirm
|
|
*
|
|
* @param array $args Positional arguments (unused).
|
|
* @param array $assoc_args Named arguments.
|
|
*/
|
|
public function cleanup_hp_transients( $args, $assoc_args ): void {
|
|
unset( $args );
|
|
$dry_run = isset( $assoc_args['dry-run'] );
|
|
$confirm = isset( $assoc_args['confirm'] );
|
|
|
|
if ( $dry_run && $confirm ) {
|
|
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
|
|
}
|
|
if ( ! $dry_run && ! $confirm ) {
|
|
WP_CLI::error(
|
|
"Refusing to run without explicit choice. Pass --dry-run or --confirm.\n" .
|
|
'Example: wp wpdo cleanup-hp-transients --dry-run'
|
|
);
|
|
}
|
|
|
|
if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
|
|
WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' );
|
|
}
|
|
|
|
$count = TMDO_Hivepress_Transient_Filter::count_legacy_postmeta_rows();
|
|
|
|
if ( $dry_run ) {
|
|
WP_CLI::success(
|
|
sprintf(
|
|
'[dry-run] %d HivePress transient row(s) in wp_postmeta would be deleted. Re-run with --confirm.',
|
|
$count
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
$deleted = TMDO_Hivepress_Transient_Filter::purge_legacy_postmeta_rows();
|
|
|
|
if ( class_exists( 'TMDO_Logger' ) ) {
|
|
TMDO_Logger::info(
|
|
'cleanup_hp_transients',
|
|
array( 'deleted' => $deleted )
|
|
);
|
|
}
|
|
|
|
WP_CLI::success(
|
|
sprintf(
|
|
'Deleted %d HivePress transient row(s) from wp_postmeta. Future writes auto-route to wp_options.',
|
|
$deleted
|
|
)
|
|
);
|
|
}
|
|
|
|
// ── post-cutover-legacy (v2.9.5) ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Non-destructive copy of a legacy `wpdo_hot_<post_type>` zone table
|
|
* into the new `wp_wpdo_post_<group>` flat table.
|
|
*
|
|
* The legacy table is left UNTOUCHED (safety net for v3.0.0 rollback).
|
|
* Idempotent — re-running is safe.
|
|
*
|
|
* Currently supports hp_listing only (the only post_type with a
|
|
* legacy hot table on production deployments). Other post_types skip
|
|
* the cutover and rely on direct wp_postmeta backfill.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* --post-type=<post_type>
|
|
* : Post type to cutover.
|
|
* ---
|
|
* default: hp_listing
|
|
* options:
|
|
* - hp_listing
|
|
* ---
|
|
*
|
|
* [--dry-run]
|
|
* : Preview row counts only.
|
|
*
|
|
* [--confirm]
|
|
* : Required to actually run the copy.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp wpdo post-cutover-legacy --dry-run
|
|
* wp wpdo post-cutover-legacy --confirm
|
|
*
|
|
* @param array $args Positional arguments (unused).
|
|
* @param array $assoc_args Named arguments.
|
|
*/
|
|
public function post_cutover_legacy( $args, $assoc_args ): void {
|
|
$post_type = (string) ( $assoc_args['post-type'] ?? 'hp_listing' );
|
|
$dry_run = isset( $assoc_args['dry-run'] );
|
|
$confirm = isset( $assoc_args['confirm'] );
|
|
|
|
if ( $dry_run && $confirm ) {
|
|
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
|
|
}
|
|
if ( ! $dry_run && ! $confirm ) {
|
|
WP_CLI::error(
|
|
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to run.\n" .
|
|
'Example: wp wpdo post-cutover-legacy --dry-run'
|
|
);
|
|
}
|
|
|
|
// Map post_type → (hot_table, flat_table, group).
|
|
$mapping = array(
|
|
'hp_listing' => array(
|
|
'hot' => 'wpdo_hot_hp_listing',
|
|
'flat' => 'wpdo_post_hp_listing_core',
|
|
'group' => 'hp_listing_core',
|
|
),
|
|
);
|
|
if ( ! isset( $mapping[ $post_type ] ) ) {
|
|
WP_CLI::error( 'Unsupported --post-type: ' . $post_type );
|
|
}
|
|
|
|
global $wpdb;
|
|
$hot_table = $wpdb->prefix . $mapping[ $post_type ]['hot'];
|
|
$flat_table = $wpdb->prefix . $mapping[ $post_type ]['flat'];
|
|
|
|
// Pre-flight: verify hot table exists and report current state.
|
|
$hot_exists = (bool) $wpdb->get_var(
|
|
$wpdb->prepare( 'SHOW TABLES LIKE %s', $hot_table )
|
|
);
|
|
if ( ! $hot_exists ) {
|
|
WP_CLI::warning( "Legacy hot table not present: {$hot_table}. Nothing to do." );
|
|
return;
|
|
}
|
|
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$hot_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hot_table}`" );
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" );
|
|
|
|
WP_CLI::log( sprintf( 'Hot (%s): %s rows', $hot_table, number_format_i18n( $hot_rows ) ) );
|
|
WP_CLI::log( sprintf( 'Flat (%s): %s rows', $flat_table, number_format_i18n( $flat_rows ) ) );
|
|
WP_CLI::log( '' );
|
|
|
|
if ( $dry_run ) {
|
|
WP_CLI::success(
|
|
sprintf(
|
|
'[dry-run] Would copy %s rows from hot → flat. Re-run with --confirm to apply.',
|
|
number_format_i18n( $hot_rows )
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
// $confirm path.
|
|
try {
|
|
$result = TMDO_Post_Migration::copy_legacy_hot_table( $post_type, $hot_table, $flat_table );
|
|
$verify = TMDO_Post_Migration::verify_legacy_cutover( $hot_table, $flat_table );
|
|
} catch ( \Throwable $e ) {
|
|
WP_CLI::error( $e->getMessage() );
|
|
}
|
|
|
|
if ( class_exists( 'TMDO_Logger' ) ) {
|
|
TMDO_Logger::info(
|
|
'post_cutover_legacy',
|
|
array(
|
|
'post_type' => $post_type,
|
|
'hot_table' => $hot_table,
|
|
'flat_table' => $flat_table,
|
|
'copied' => $result['copied'],
|
|
'common_columns' => $result['common_columns'],
|
|
'verify' => $verify,
|
|
)
|
|
);
|
|
}
|
|
|
|
WP_CLI::log( sprintf( 'Copied %d row(s). Common columns: %s', $result['copied'], implode( ', ', $result['common_columns'] ) ) );
|
|
WP_CLI::log(
|
|
sprintf(
|
|
'Verify: hot=%d flat=%d mismatched=%d ok=%s',
|
|
$verify['hot_rows'],
|
|
$verify['flat_rows'],
|
|
$verify['mismatched_rows'],
|
|
$verify['ok'] ? 'yes' : 'NO'
|
|
)
|
|
);
|
|
|
|
if ( ! $verify['ok'] ) {
|
|
WP_CLI::error( 'Verification failed — flat table missing rows. Legacy hot table left intact for retry.' );
|
|
}
|
|
|
|
WP_CLI::success(
|
|
sprintf(
|
|
'Legacy cutover complete (%s). Hot table preserved as safety net for v3.0.0 rollback.',
|
|
$post_type
|
|
)
|
|
);
|
|
}
|
|
|
|
// ── post-benchmark (v2.10.2) ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Compare query latency between wp_postmeta JOIN and flat-table JOIN
|
|
* for a single meta_key/value combination.
|
|
*
|
|
* Speed-up = postmeta_avg_ms / flat_avg_ms. Higher is better.
|
|
*
|
|
* Read-only — does not modify any table or change post mode. Safe to run
|
|
* in any post mode (the benchmark always queries both paths regardless).
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* --post-type=<post_type>
|
|
* : Post type to filter by (e.g. product, hp_listing).
|
|
*
|
|
* --meta-key=<key>
|
|
* : Meta key to query (must be registered in entity registry for $post_type).
|
|
*
|
|
* [--compare=<op>]
|
|
* : Comparison operator. Default: =.
|
|
* ---
|
|
* default: =
|
|
* options:
|
|
* - "="
|
|
* - "!="
|
|
* - "<"
|
|
* - "<="
|
|
* - ">"
|
|
* - ">="
|
|
* - "LIKE"
|
|
* ---
|
|
*
|
|
* --value=<value>
|
|
* : Value to compare against.
|
|
*
|
|
* [--samples=<n>]
|
|
* : Number of times to run each query. Default: 50.
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp wpdo post-benchmark --post-type=hp_listing --meta-key=hp_price --compare=">=" --value=100
|
|
* wp wpdo post-benchmark --post-type=product --meta-key=_price --compare="=" --value=99 --samples=200
|
|
*
|
|
* @param array $args Positional arguments (unused).
|
|
* @param array $assoc_args Named arguments.
|
|
*/
|
|
public function post_benchmark( $args, $assoc_args ): void {
|
|
$post_type = (string) ( $assoc_args['post-type'] ?? '' );
|
|
$meta_key = (string) ( $assoc_args['meta-key'] ?? '' );
|
|
$compare = (string) ( $assoc_args['compare'] ?? '=' );
|
|
$value = (string) ( $assoc_args['value'] ?? '' );
|
|
$samples = max( 1, (int) ( $assoc_args['samples'] ?? 50 ) );
|
|
|
|
if ( '' === $post_type || '' === $meta_key ) {
|
|
WP_CLI::error( 'Both --post-type and --meta-key are required.' );
|
|
}
|
|
|
|
// Look up the entity group for this meta_key to find the flat table.
|
|
$field = TMDO_Entity_Registry::get_field( 'post', $meta_key );
|
|
if ( ! $field ) {
|
|
WP_CLI::error( "Meta key '{$meta_key}' is not registered for entity_type='post'." );
|
|
}
|
|
|
|
global $wpdb;
|
|
$flat_table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $field['group'] );
|
|
|
|
try {
|
|
$result = TMDO_Post_Migration::benchmark_query(
|
|
$post_type,
|
|
$meta_key,
|
|
$compare,
|
|
$value,
|
|
$flat_table,
|
|
$samples
|
|
);
|
|
} catch ( \Throwable $e ) {
|
|
WP_CLI::error( $e->getMessage() );
|
|
}
|
|
|
|
WP_CLI::log(
|
|
sprintf(
|
|
'Benchmark: %s.%s %s %s (samples=%d)',
|
|
$post_type,
|
|
$meta_key,
|
|
$compare,
|
|
$value,
|
|
$samples
|
|
)
|
|
);
|
|
WP_CLI::log( '' );
|
|
WP_CLI::log(
|
|
sprintf(
|
|
' postmeta JOIN: %.3f ms avg (matched %d rows)',
|
|
$result['postmeta_avg_ms'],
|
|
$result['postmeta_rows']
|
|
)
|
|
);
|
|
WP_CLI::log(
|
|
sprintf(
|
|
' flat JOIN: %.3f ms avg (matched %d rows)',
|
|
$result['flat_avg_ms'],
|
|
$result['flat_rows']
|
|
)
|
|
);
|
|
WP_CLI::log( '' );
|
|
WP_CLI::log( sprintf( ' → Speedup: %.2fx', $result['speedup'] ) );
|
|
|
|
// Persist to wpdo_benchmarks table.
|
|
$bench_table = $wpdb->prefix . 'wpdo_benchmarks';
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
|
$wpdb->insert(
|
|
$bench_table,
|
|
array(
|
|
'module' => 'post_router_' . $post_type,
|
|
'zone' => 'flat',
|
|
'query_type' => 'meta_query_' . $meta_key,
|
|
'native_ms' => $result['postmeta_avg_ms'],
|
|
'custom_ms' => $result['flat_avg_ms'],
|
|
'sample_size' => $samples,
|
|
'created_at' => gmdate( 'Y-m-d H:i:s' ),
|
|
),
|
|
array( '%s', '%s', '%s', '%f', '%f', '%d', '%s' )
|
|
);
|
|
|
|
WP_CLI::success( sprintf( 'Benchmark recorded to %s.', $bench_table ) );
|
|
}
|
|
|
|
// ── post-shadow-report (v2.10.3) ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Show shadow_read divergence stats for entity_type='post'.
|
|
*
|
|
* When post mode is shadow_read, the TMDO_Post_Shadow_Verifier cron job
|
|
* runs hourly and writes any flat vs wp_postmeta divergences to
|
|
* wpdo_shadow_diffs. This command surfaces a 24h aggregate.
|
|
*
|
|
* Optionally runs an immediate sample-compare pass via --run-now.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* [--hours=<n>]
|
|
* : Aggregation window in hours. Default: 24.
|
|
*
|
|
* [--limit=<n>]
|
|
* : Max recent diff rows to display. Default: 10.
|
|
*
|
|
* [--run-now]
|
|
* : Execute one sample-compare tick immediately (alongside the report).
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp wpdo post-shadow-report
|
|
* wp wpdo post-shadow-report --hours=48 --limit=20
|
|
* wp wpdo post-shadow-report --run-now
|
|
*
|
|
* @param array $args Positional arguments (unused).
|
|
* @param array $assoc_args Named arguments.
|
|
*/
|
|
public function post_shadow_report( $args, $assoc_args ): void {
|
|
$hours = max( 1, (int) ( $assoc_args['hours'] ?? 24 ) );
|
|
$limit = max( 1, (int) ( $assoc_args['limit'] ?? 10 ) );
|
|
$run_now = isset( $assoc_args['run-now'] );
|
|
$mode = TMDO_Mode_Manager::get( 'post' );
|
|
|
|
WP_CLI::log( sprintf( 'Post mode: %s', $mode ) );
|
|
WP_CLI::log( sprintf( 'Window: last %d hours', $hours ) );
|
|
WP_CLI::log( '' );
|
|
|
|
if ( $run_now ) {
|
|
WP_CLI::log( 'Running sample-compare for all groups...' );
|
|
$totals = array(
|
|
'sampled' => 0,
|
|
'matched' => 0,
|
|
'diffs' => 0,
|
|
'missing_flat' => 0,
|
|
'missing_postmeta' => 0,
|
|
);
|
|
global $wpdb;
|
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
|
$keys = TMDO_Entity_Registry::get_group_keys( 'post', $group );
|
|
if ( empty( $keys ) ) {
|
|
continue;
|
|
}
|
|
// Use the verifier's own group→post_type mapping reflectively.
|
|
$pt = self::group_post_type_for_cli( $group );
|
|
if ( null === $pt ) {
|
|
continue;
|
|
}
|
|
$flat = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
|
|
try {
|
|
$res = TMDO_Post_Shadow_Verifier::sample_compare( $pt, $group, $flat, $keys, 50 );
|
|
foreach ( $totals as $k => $_ ) {
|
|
$totals[ $k ] += (int) ( $res[ $k ] ?? 0 );
|
|
}
|
|
WP_CLI::log(
|
|
sprintf(
|
|
' %-20s sampled=%d matched=%d diffs=%d miss_flat=%d miss_pm=%d',
|
|
$group,
|
|
$res['sampled'],
|
|
$res['matched'],
|
|
$res['diffs'],
|
|
$res['missing_flat'],
|
|
$res['missing_postmeta']
|
|
)
|
|
);
|
|
} catch ( \Throwable $e ) {
|
|
WP_CLI::warning( " {$group}: " . $e->getMessage() );
|
|
}
|
|
}
|
|
WP_CLI::log( '' );
|
|
WP_CLI::log(
|
|
sprintf(
|
|
'Run-now totals: sampled=%d matched=%d diffs=%d miss_flat=%d miss_pm=%d',
|
|
$totals['sampled'],
|
|
$totals['matched'],
|
|
$totals['diffs'],
|
|
$totals['missing_flat'],
|
|
$totals['missing_postmeta']
|
|
)
|
|
);
|
|
WP_CLI::log( '' );
|
|
}
|
|
|
|
// 24h aggregate from wpdo_shadow_diffs.
|
|
$stats = TMDO_Post_Shadow_Verifier::diff_stats( $hours );
|
|
WP_CLI::log( sprintf( 'Total diffs (last %dh): %d', $hours, $stats['total'] ) );
|
|
if ( ! empty( $stats['by_key'] ) ) {
|
|
WP_CLI::log( '' );
|
|
$rows = array();
|
|
foreach ( $stats['by_key'] as $key => $count ) {
|
|
$rows[] = array(
|
|
'meta_key' => $key,
|
|
'diffs' => $count,
|
|
);
|
|
}
|
|
WP_CLI\Utils\format_items( 'table', $rows, array( 'meta_key', 'diffs' ) );
|
|
}
|
|
|
|
// Recent diff rows.
|
|
if ( $stats['total'] > 0 ) {
|
|
$recent = TMDO_Post_Shadow_Verifier::recent_diffs( $limit );
|
|
if ( ! empty( $recent ) ) {
|
|
WP_CLI::log( '' );
|
|
WP_CLI::log( sprintf( 'Recent %d diff(s):', $limit ) );
|
|
WP_CLI\Utils\format_items( 'table', $recent, array( 'entity_id', 'meta_key', 'postmeta_value', 'zone_value', 'ts' ) );
|
|
}
|
|
}
|
|
|
|
WP_CLI::success( 'Shadow report complete.' );
|
|
}
|
|
|
|
/**
|
|
* Helper: map entity group to its primary post_type for the run-now CLI path.
|
|
* Mirror of TMDO_Post_Shadow_Verifier's private mapping.
|
|
*
|
|
* @param string $group Entity group name.
|
|
* @return string|null
|
|
*/
|
|
private static function group_post_type_for_cli( string $group ): ?string {
|
|
switch ( $group ) {
|
|
case 'attachment':
|
|
return 'attachment';
|
|
case 'wc_product':
|
|
return 'product';
|
|
case 'hp_listing_core':
|
|
return 'hp_listing';
|
|
case 'hp_request_core':
|
|
return 'hp_request';
|
|
case 'hp_vendor_core':
|
|
return 'hp_vendor';
|
|
case 'nav_menu_item':
|
|
return 'nav_menu_item';
|
|
case 'wp_core':
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Register subcommands ──────────────────────────────────────────────────────
|
|
WP_CLI::add_command( 'wpdo postmeta-cleanup', array( 'TMDO_CLI_Post', 'postmeta_cleanup' ) );
|
|
WP_CLI::add_command( 'tmdo postmeta-cleanup', array( 'TMDO_CLI_Post', 'postmeta_cleanup' ) );
|
|
WP_CLI::add_command( 'wpdo post-diagnose', array( 'TMDO_CLI_Post', 'post_diagnose' ) );
|
|
WP_CLI::add_command( 'tmdo post-diagnose', array( 'TMDO_CLI_Post', 'post_diagnose' ) );
|
|
WP_CLI::add_command( 'wpdo post-migrate-group', array( 'TMDO_CLI_Post', 'post_migrate_group' ) );
|
|
WP_CLI::add_command( 'tmdo post-migrate-group', array( 'TMDO_CLI_Post', 'post_migrate_group' ) );
|
|
WP_CLI::add_command( 'wpdo post-cleanup', array( 'TMDO_CLI_Post', 'post_cleanup' ) );
|
|
WP_CLI::add_command( 'tmdo post-cleanup', array( 'TMDO_CLI_Post', 'post_cleanup' ) );
|
|
WP_CLI::add_command( 'wpdo post-cutover-legacy', array( 'TMDO_CLI_Post', 'post_cutover_legacy' ) );
|
|
WP_CLI::add_command( 'tmdo post-cutover-legacy', array( 'TMDO_CLI_Post', 'post_cutover_legacy' ) );
|
|
WP_CLI::add_command( 'wpdo cleanup-hp-transients', array( 'TMDO_CLI_Post', 'cleanup_hp_transients' ) );
|
|
WP_CLI::add_command( 'tmdo cleanup-hp-transients', array( 'TMDO_CLI_Post', 'cleanup_hp_transients' ) );
|
|
WP_CLI::add_command( 'wpdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
|
|
WP_CLI::add_command( 'tmdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
|
|
WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|
|
WP_CLI::add_command( 'tmdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|