chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
/**
|
||||
* Zone D (Archive) migration for postmeta from trashed/old posts.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone D (Archive) migration — postmeta from trashed/old posts → wpdo_archive.
|
||||
*
|
||||
* Unlike other zone migrations that copy specific registered meta_keys,
|
||||
* the archive migration sweeps ALL postmeta for posts that meet archival criteria:
|
||||
* - Post is in 'trash' status
|
||||
* - Post was last modified more than $days ago (default: 90 days)
|
||||
*
|
||||
* Data is optionally gzip-compressed before storage.
|
||||
*/
|
||||
class TMDO_Archive_Migration extends TMDO_Migration_Base {
|
||||
|
||||
/**
|
||||
* Minimum age in days for archival.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private int $days;
|
||||
|
||||
/**
|
||||
* Whether to gzip-compress archived values.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private bool $compress;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param int $days Minimum age in days for archival.
|
||||
* @param bool $compress Whether to gzip-compress archived values.
|
||||
*/
|
||||
public function __construct( int $days = 90, bool $compress = true ) {
|
||||
$this->days = $days;
|
||||
$this->compress = $compress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the module identifier.
|
||||
*
|
||||
* @return string Module name.
|
||||
*/
|
||||
public function get_module(): string {
|
||||
return 'archive';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the zone identifier.
|
||||
*
|
||||
* @return string Zone name.
|
||||
*/
|
||||
public function get_zone(): string {
|
||||
return 'archive';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of archive-eligible postmeta rows.
|
||||
*
|
||||
* @return int Total row count.
|
||||
*/
|
||||
protected function count_source(): int {
|
||||
global $wpdb;
|
||||
|
||||
$cutoff = $this->get_cutoff();
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_status = 'trash'
|
||||
AND p.post_modified_gmt < %s",
|
||||
$cutoff
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates one batch of eligible postmeta rows to the archive table.
|
||||
*
|
||||
* @param int $offset Starting row offset.
|
||||
* @return int Number of rows processed.
|
||||
*/
|
||||
protected function migrate_batch( int $offset ): int {
|
||||
global $wpdb;
|
||||
|
||||
$cutoff = $this->get_cutoff();
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_status = 'trash'
|
||||
AND p.post_modified_gmt < %s
|
||||
ORDER BY pm.meta_id ASC
|
||||
LIMIT %d OFFSET %d",
|
||||
$cutoff,
|
||||
self::BATCH_SIZE,
|
||||
$offset
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( empty( $rows ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$entries = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$entries[] = array(
|
||||
'post_id' => $row['post_id'],
|
||||
'post_type' => $row['post_type'],
|
||||
'meta_key' => $row['meta_key'],
|
||||
'meta_value' => $row['meta_value'],
|
||||
'meta_id' => $row['meta_id'],
|
||||
);
|
||||
}
|
||||
|
||||
TMDO_Zone_Archive::archive_batch( $entries, $this->compress );
|
||||
|
||||
return count( $rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that archive table has rows when eligible source rows exist.
|
||||
*
|
||||
* @return bool True if verification passes.
|
||||
*/
|
||||
public function verify_counts(): bool {
|
||||
global $wpdb;
|
||||
|
||||
$source = $this->count_source();
|
||||
$table = TMDO_Zone_Archive::table();
|
||||
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Archive::table()
|
||||
|
||||
// Archive may have more rows than current source (trashed posts may have
|
||||
// been permanently deleted after archival). So we just check target > 0
|
||||
// when source is 0, or target >= some threshold.
|
||||
if ( 0 === $source ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $target >= $source;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the cutoff datetime string for archival eligibility.
|
||||
*
|
||||
* @return string MySQL datetime string.
|
||||
*/
|
||||
private function get_cutoff(): string {
|
||||
return gmdate( 'Y-m-d H:i:s', time() - ( $this->days * DAY_IN_SECONDS ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
/**
|
||||
* Zone C (Cold) migration for postmeta to JSON blob cold table.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone C (Cold) migration — postmeta → JSON blob cold table.
|
||||
*
|
||||
* For each post, gathers all registered cold meta_keys and stores them
|
||||
* as a single JSON blob in wpdo_cold_{post_type}.
|
||||
*/
|
||||
class TMDO_Cold_Migration extends TMDO_Migration_Base {
|
||||
|
||||
/**
|
||||
* Post type being migrated.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $post_type;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $post_type Post type slug to migrate.
|
||||
*/
|
||||
public function __construct( string $post_type ) {
|
||||
$this->post_type = $post_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the module identifier.
|
||||
*
|
||||
* @return string Module name.
|
||||
*/
|
||||
public function get_module(): string {
|
||||
return 'cold_' . sanitize_key( $this->post_type );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the zone identifier.
|
||||
*
|
||||
* @return string Zone name.
|
||||
*/
|
||||
public function get_zone(): string {
|
||||
return 'cold';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of posts to migrate.
|
||||
*
|
||||
* @return int Total post count.
|
||||
*/
|
||||
protected function count_source(): int {
|
||||
global $wpdb;
|
||||
|
||||
$meta_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $this->post_type );
|
||||
if ( empty( $meta_keys ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
|
||||
$args = array_merge( array( $this->post_type ), $meta_keys );
|
||||
|
||||
// Count distinct posts that have at least one cold field.
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT pm.post_id)
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})",
|
||||
...$args
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates one batch of posts to the cold table.
|
||||
*
|
||||
* @param int $offset Starting post offset.
|
||||
* @return int Number of posts processed.
|
||||
*/
|
||||
protected function migrate_batch( int $offset ): int {
|
||||
global $wpdb;
|
||||
|
||||
$meta_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $this->post_type );
|
||||
if ( empty( $meta_keys ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
|
||||
|
||||
// Get batch of distinct post IDs.
|
||||
$args = array_merge( array( $this->post_type ), $meta_keys, array( self::BATCH_SIZE, $offset ) );
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders/$id_placeholders from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
|
||||
$post_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT pm.post_id
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})
|
||||
ORDER BY pm.post_id ASC
|
||||
LIMIT %d OFFSET %d",
|
||||
...$args
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
|
||||
// Ensure cold table exists before any write (even if this batch is empty).
|
||||
TMDO_Zone_Cold::ensure_table( $this->post_type );
|
||||
|
||||
if ( empty( $post_ids ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fetch all cold meta for these posts.
|
||||
$id_placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
$meta_rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT post_id, meta_key, meta_value
|
||||
FROM {$wpdb->postmeta}
|
||||
WHERE post_id IN ({$id_placeholders}) AND meta_key IN ({$placeholders})",
|
||||
...array_merge( array_map( 'intval', $post_ids ), $meta_keys )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
|
||||
// Group by post_id.
|
||||
$grouped = array();
|
||||
foreach ( $meta_rows ?: array() as $row ) {
|
||||
$grouped[ $row['post_id'] ][ $row['meta_key'] ] = $row['meta_value'];
|
||||
}
|
||||
|
||||
// Write each post's cold data as a JSON blob.
|
||||
foreach ( $grouped as $pid => $data ) {
|
||||
TMDO_Zone_Cold::set_many( (int) $pid, $this->post_type, $data );
|
||||
}
|
||||
|
||||
return count( $post_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the cold table row count is at least as large as the source.
|
||||
*
|
||||
* @return bool True if verification passes.
|
||||
*/
|
||||
public function verify_counts(): bool {
|
||||
global $wpdb;
|
||||
|
||||
$source = $this->count_source();
|
||||
$table = TMDO_Zone_Cold::table( $this->post_type );
|
||||
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Cold::table()
|
||||
|
||||
return $target >= $source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
/**
|
||||
* Zone A (Hot) migration for postmeta to flat-column hot table.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone A (Hot) migration — postmeta → flat-column hot table.
|
||||
*
|
||||
* Reads registered hot fields from Schema Registry for a specific post type,
|
||||
* bulk-copies their postmeta values into wpdo_hot_{post_type}.
|
||||
*/
|
||||
class TMDO_Hot_Migration extends TMDO_Migration_Base {
|
||||
|
||||
/**
|
||||
* Post type being migrated.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $post_type;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $post_type Post type slug to migrate.
|
||||
*/
|
||||
public function __construct( string $post_type ) {
|
||||
$this->post_type = $post_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the module identifier.
|
||||
*
|
||||
* @return string Module name.
|
||||
*/
|
||||
public function get_module(): string {
|
||||
return 'hot_' . sanitize_key( $this->post_type );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the zone identifier.
|
||||
*
|
||||
* @return string Zone name.
|
||||
*/
|
||||
public function get_zone(): string {
|
||||
return 'hot';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of posts to migrate.
|
||||
*
|
||||
* @return int Total post count.
|
||||
*/
|
||||
protected function count_source(): int {
|
||||
global $wpdb;
|
||||
|
||||
$meta_keys = $this->get_meta_keys();
|
||||
if ( empty( $meta_keys ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
|
||||
$args = array_merge( array( $this->post_type ), $meta_keys );
|
||||
|
||||
// Count distinct posts that have at least one hot field.
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders built from array_fill with %s; $wpdb->postmeta/$wpdb->posts are core properties.
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT pm.post_id)
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})",
|
||||
...$args
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates one batch of posts to the hot table.
|
||||
*
|
||||
* @param int $offset Starting post offset.
|
||||
* @return int Number of posts processed.
|
||||
*/
|
||||
protected function migrate_batch( int $offset ): int {
|
||||
global $wpdb;
|
||||
|
||||
$meta_keys = $this->get_meta_keys();
|
||||
if ( empty( $meta_keys ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $this->post_type );
|
||||
$key_map = $this->build_key_to_column_map();
|
||||
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
|
||||
|
||||
// Get batch of distinct post IDs.
|
||||
$args = array_merge( array( $this->post_type ), $meta_keys, array( self::BATCH_SIZE, $offset ) );
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders/$id_placeholders built from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
|
||||
$post_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT pm.post_id
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})
|
||||
ORDER BY pm.post_id ASC
|
||||
LIMIT %d OFFSET %d",
|
||||
...$args
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $post_ids ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Ensure hot table exists.
|
||||
TMDO_Zone_Hot::ensure_table( $this->post_type );
|
||||
|
||||
// For each post, gather all hot meta and upsert.
|
||||
$id_placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
|
||||
|
||||
$meta_rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT post_id, meta_key, meta_value
|
||||
FROM {$wpdb->postmeta}
|
||||
WHERE post_id IN ({$id_placeholders}) AND meta_key IN ({$placeholders})",
|
||||
...array_merge( array_map( 'intval', $post_ids ), $meta_keys )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
|
||||
// Group by post_id.
|
||||
$grouped = array();
|
||||
foreach ( $meta_rows ?: array() as $row ) {
|
||||
$col = $key_map[ $row['meta_key'] ] ?? null;
|
||||
if ( $col ) {
|
||||
$grouped[ $row['post_id'] ][ $col ] = $row['meta_value'];
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert each post's hot data.
|
||||
foreach ( $grouped as $pid => $data ) {
|
||||
TMDO_Zone_Hot::set_many( (int) $pid, $this->post_type, $data );
|
||||
}
|
||||
|
||||
return count( $post_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the hot table row count is at least as large as the source.
|
||||
*
|
||||
* @return bool True if verification passes.
|
||||
*/
|
||||
public function verify_counts(): bool {
|
||||
global $wpdb;
|
||||
|
||||
$source = $this->count_source();
|
||||
$table = TMDO_Zone_Hot::table( $this->post_type );
|
||||
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Hot::table()
|
||||
|
||||
return $target >= $source;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all registered hot meta_keys for this post type.
|
||||
*/
|
||||
private function get_meta_keys(): array {
|
||||
$fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'hot', $this->post_type );
|
||||
return array_column( $fields, 'meta_key' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build meta_key → column_name map.
|
||||
*/
|
||||
private function build_key_to_column_map(): array {
|
||||
$fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'hot', $this->post_type );
|
||||
$map = array();
|
||||
foreach ( $fields as $field ) {
|
||||
$map[ $field['meta_key'] ] = $field['column'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
/**
|
||||
* Abstract base for all WPDO data migrations.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base for all WPDO data migrations.
|
||||
*
|
||||
* Each subclass implements:
|
||||
* - get_module() — module name (TMDO_Feature_Flags key)
|
||||
* - get_zone() — zone identifier ('' for HPCT modules, 'hot'/'warm'/'cold'/'archive' for zone modules)
|
||||
* - count_source() — total number of native rows to migrate
|
||||
* - migrate_batch() — migrate one batch starting at $offset, returns rows processed
|
||||
* - verify_counts() — returns true when custom table count >= native count
|
||||
*
|
||||
* Migration record is tracked in wpdo_migrations table.
|
||||
* Batch size: 500 rows. Timeout: 28 seconds per run.
|
||||
*/
|
||||
abstract class TMDO_Migration_Base {
|
||||
|
||||
protected const BATCH_SIZE = 500;
|
||||
protected const TIMEOUT = 28; // Seconds.
|
||||
|
||||
/**
|
||||
* Returns the module identifier for this migration.
|
||||
*
|
||||
* @return string Module name.
|
||||
*/
|
||||
abstract public function get_module(): string;
|
||||
|
||||
/**
|
||||
* Zone identifier. Empty string for HPCT-inherited modules.
|
||||
*/
|
||||
public function get_zone(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of source rows to migrate.
|
||||
*
|
||||
* @return int Total row count.
|
||||
*/
|
||||
abstract protected function count_source(): int;
|
||||
|
||||
/**
|
||||
* Migrate one batch of rows.
|
||||
*
|
||||
* @param int $offset Starting row offset.
|
||||
* @return int Number of rows processed in this batch.
|
||||
*/
|
||||
abstract protected function migrate_batch( int $offset ): int;
|
||||
|
||||
/**
|
||||
* Verify that the custom table count is >= native count.
|
||||
*/
|
||||
abstract public function verify_counts(): bool;
|
||||
|
||||
// ── Migration record helpers ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retrieves the current migration record from the database.
|
||||
*
|
||||
* @return array|null Migration record array, or null if not found.
|
||||
*/
|
||||
public function get_record(): ?array {
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( 'wpdo_migrations' );
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare( "SELECT * FROM `{$table}` WHERE module = %s", $this->get_module() ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize or reset the migration record.
|
||||
*/
|
||||
public function init_record(): void {
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( 'wpdo_migrations' );
|
||||
$now = TMDO_DB::now();
|
||||
$total = $this->count_source();
|
||||
|
||||
$existing = $this->get_record();
|
||||
|
||||
if ( $existing ) {
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array(
|
||||
'state' => 'backfill',
|
||||
'zone' => $this->get_zone(),
|
||||
'total_rows' => $total,
|
||||
'processed_rows' => 0,
|
||||
'last_offset' => 0,
|
||||
'error_count' => 0,
|
||||
'started_at' => $now,
|
||||
'completed_at' => null,
|
||||
'updated_at' => $now,
|
||||
),
|
||||
array( 'module' => $this->get_module() ),
|
||||
array( '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' ),
|
||||
array( '%s' )
|
||||
);
|
||||
} else {
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'module' => $this->get_module(),
|
||||
'zone' => $this->get_zone(),
|
||||
'state' => 'backfill',
|
||||
'total_rows' => $total,
|
||||
'processed_rows' => 0,
|
||||
'last_offset' => 0,
|
||||
'error_count' => 0,
|
||||
'started_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
),
|
||||
array( '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an existing migration (does NOT reset processed_rows).
|
||||
*/
|
||||
public function resume_record(): void {
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( 'wpdo_migrations' );
|
||||
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array(
|
||||
'state' => 'backfill',
|
||||
'updated_at' => TMDO_DB::now(),
|
||||
),
|
||||
array( 'module' => $this->get_module() ),
|
||||
array( '%s', '%s' ),
|
||||
array( '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress after each batch.
|
||||
*
|
||||
* @param int $processed Number of rows processed in this batch.
|
||||
* @param int $last_offset Last row offset processed.
|
||||
* @return void
|
||||
*/
|
||||
public function update_progress( int $processed, int $last_offset ): void {
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( 'wpdo_migrations' );
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE `{$table}` SET
|
||||
processed_rows = processed_rows + %d,
|
||||
last_offset = %d,
|
||||
updated_at = %s
|
||||
WHERE module = %s",
|
||||
$processed,
|
||||
$last_offset,
|
||||
TMDO_DB::now(),
|
||||
$this->get_module()
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark migration as completed (state → verify).
|
||||
*/
|
||||
public function mark_complete(): void {
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( 'wpdo_migrations' );
|
||||
$now = TMDO_DB::now();
|
||||
|
||||
$wpdb->update(
|
||||
$table,
|
||||
array(
|
||||
'state' => 'verify',
|
||||
'completed_at' => $now,
|
||||
'updated_at' => $now,
|
||||
),
|
||||
array( 'module' => $this->get_module() ),
|
||||
array( '%s', '%s', '%s' ),
|
||||
array( '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment error count.
|
||||
*/
|
||||
public function increment_errors(): void {
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( 'wpdo_migrations' );
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE `{$table}` SET error_count = error_count + 1, updated_at = %s WHERE module = %s",
|
||||
TMDO_DB::now(),
|
||||
$this->get_module()
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the migration in batches. Stops after ~28 seconds.
|
||||
*
|
||||
* @param bool $resume If true, resumes from last_offset.
|
||||
* @param callable|null $progress_callback Called after each batch with (processed, total).
|
||||
* @return bool True if migration completed, false if timed out (resume needed).
|
||||
*/
|
||||
public function run( bool $resume = false, ?callable $progress_callback = null ): bool {
|
||||
// Set feature flag to dual_write before starting.
|
||||
TMDO_Feature_Flags::set( $this->get_module(), 'dual_write' );
|
||||
|
||||
if ( $resume ) {
|
||||
$record = $this->get_record();
|
||||
$offset = (int) ( $record['last_offset'] ?? 0 );
|
||||
$this->resume_record();
|
||||
} else {
|
||||
$offset = 0;
|
||||
$this->init_record();
|
||||
}
|
||||
|
||||
// Advance to backfill state.
|
||||
TMDO_Feature_Flags::set( $this->get_module(), 'backfill' );
|
||||
|
||||
$start = microtime( true );
|
||||
|
||||
do {
|
||||
try {
|
||||
$batch_count = $this->migrate_batch( $offset );
|
||||
} catch ( \Throwable $e ) {
|
||||
$this->increment_errors();
|
||||
TMDO_Logger::error(
|
||||
$this->get_module(),
|
||||
'migrate_batch',
|
||||
$e->getMessage(),
|
||||
array(
|
||||
'offset' => $offset,
|
||||
'zone' => $this->get_zone(),
|
||||
)
|
||||
);
|
||||
$batch_count = 0;
|
||||
|
||||
// Check if too many errors.
|
||||
$record = $this->get_record();
|
||||
if ( $record && (int) $record['error_count'] >= 10 ) {
|
||||
TMDO_Feature_Flags::reset( $this->get_module() );
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $batch_count > 0 ) {
|
||||
$this->update_progress( $batch_count, $offset + $batch_count );
|
||||
$offset += $batch_count;
|
||||
}
|
||||
|
||||
if ( $progress_callback ) {
|
||||
$record = $this->get_record();
|
||||
$progress_callback( (int) $record['processed_rows'], (int) $record['total_rows'] );
|
||||
}
|
||||
|
||||
if ( ( microtime( true ) - $start ) > self::TIMEOUT ) {
|
||||
return false; // Timed out — next run will resume.
|
||||
}
|
||||
} while ( $batch_count >= self::BATCH_SIZE );
|
||||
|
||||
// Backfill done → move to verify.
|
||||
$this->mark_complete();
|
||||
TMDO_Feature_Flags::set( $this->get_module(), 'verify' );
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration Engine for 7-state module lifecycle control.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration Engine — 7-state lifecycle controller.
|
||||
*
|
||||
* Manages the full migration lifecycle for any module:
|
||||
* idle → dual_write → backfill → verify → cutover → cleanup → complete
|
||||
*
|
||||
* Provides:
|
||||
* - State transitions with validation
|
||||
* - Orchestration of backfill + verify + cutover + cleanup steps
|
||||
* - Rollback to idle from any state
|
||||
* - CLI and Admin integration points
|
||||
*
|
||||
* Each module has a corresponding TMDO_Migration_Base subclass that handles
|
||||
* the actual data migration logic (batch processing, counting, verifying).
|
||||
*/
|
||||
class TMDO_Migration_Engine {
|
||||
|
||||
/**
|
||||
* Valid state transitions.
|
||||
* Key = current state, value = array of allowed next states.
|
||||
* 'idle' is always allowed from any state (rollback).
|
||||
*/
|
||||
private const TRANSITIONS = array(
|
||||
'idle' => array( 'dual_write' ),
|
||||
'dual_write' => array( 'backfill', 'idle' ),
|
||||
'backfill' => array( 'verify', 'dual_write', 'idle' ),
|
||||
'verify' => array( 'cutover', 'dual_write', 'idle' ),
|
||||
'cutover' => array( 'cleanup', 'idle' ),
|
||||
'cleanup' => array( 'complete', 'idle' ),
|
||||
'complete' => array( 'idle' ),
|
||||
);
|
||||
|
||||
/**
|
||||
* Registry of migration class instances, keyed by module name.
|
||||
*
|
||||
* @var array<string, TMDO_Migration_Base>
|
||||
*/
|
||||
private static array $migrations = array();
|
||||
|
||||
/**
|
||||
* Register a migration class for a module.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @param TMDO_Migration_Base $migration Migration instance.
|
||||
* @return void
|
||||
*/
|
||||
public static function register( string $module, TMDO_Migration_Base $migration ): void {
|
||||
self::$migrations[ $module ] = $migration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration instance for a module.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return TMDO_Migration_Base|null Migration instance, or null if not found.
|
||||
*/
|
||||
public static function get_migration( string $module ): ?TMDO_Migration_Base {
|
||||
return self::$migrations[ $module ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered migration instances.
|
||||
*
|
||||
* @return array<string, TMDO_Migration_Base>
|
||||
*/
|
||||
public static function all(): array {
|
||||
return self::$migrations;
|
||||
}
|
||||
|
||||
// ── State transitions ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a state transition is valid.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @param string $target_state Target state to transition to.
|
||||
* @return bool True if the transition is allowed.
|
||||
*/
|
||||
public static function can_transition( string $module, string $target_state ): bool {
|
||||
$current = TMDO_Feature_Flags::get( $module );
|
||||
|
||||
// Rollback to idle is always allowed.
|
||||
if ( 'idle' === $target_state ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$allowed = self::TRANSITIONS[ $current ] ?? array();
|
||||
return in_array( $target_state, $allowed, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a module to a new state.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @param string $target_state Target state to transition to.
|
||||
* @return bool True on success, false if transition is invalid.
|
||||
*/
|
||||
public static function transition( string $module, string $target_state ): bool {
|
||||
if ( ! self::can_transition( $module, $target_state ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return TMDO_Feature_Flags::set( $module, $target_state );
|
||||
}
|
||||
|
||||
// ── High-level operations ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start or resume migration for a module.
|
||||
*
|
||||
* Flow: idle → dual_write → backfill (run batches)
|
||||
*
|
||||
* @param string $module Module name.
|
||||
* @param callable|null $progress_callback Called after each batch.
|
||||
* @return array{status: string, message: string}
|
||||
*/
|
||||
public static function migrate( string $module, ?callable $progress_callback = null ): array {
|
||||
$migration = self::get_migration( $module );
|
||||
if ( ! $migration ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => "No migration registered for module: {$module}",
|
||||
);
|
||||
}
|
||||
|
||||
$current = TMDO_Feature_Flags::get( $module );
|
||||
|
||||
// Already in backfill — resume.
|
||||
if ( 'backfill' === $current ) {
|
||||
$completed = $migration->run( true, $progress_callback );
|
||||
return array(
|
||||
'status' => $completed ? 'verify' : 'backfill',
|
||||
'message' => $completed ? 'Backfill complete. Ready to verify.' : 'Backfill timed out. Resume to continue.',
|
||||
);
|
||||
}
|
||||
|
||||
// Already past backfill.
|
||||
if ( in_array( $current, array( 'verify', 'cutover', 'cleanup', 'complete' ), true ) ) {
|
||||
return array(
|
||||
'status' => $current,
|
||||
'message' => "Module is already in state: {$current}.",
|
||||
);
|
||||
}
|
||||
|
||||
// Start fresh: idle or dual_write → backfill.
|
||||
if ( ! self::transition( $module, 'dual_write' ) && 'dual_write' !== $current ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => "Cannot start migration from state: {$current}",
|
||||
);
|
||||
}
|
||||
|
||||
$completed = $migration->run( false, $progress_callback );
|
||||
|
||||
return array(
|
||||
'status' => $completed ? 'verify' : 'backfill',
|
||||
'message' => $completed ? 'Backfill complete. Ready to verify.' : 'Backfill timed out. Resume to continue.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify data consistency for a module.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return array{status: string, message: string, verified: bool} Verification result.
|
||||
*/
|
||||
public static function verify( string $module ): array {
|
||||
$migration = self::get_migration( $module );
|
||||
if ( ! $migration ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => "No migration registered for module: {$module}",
|
||||
'verified' => false,
|
||||
);
|
||||
}
|
||||
|
||||
$current = TMDO_Feature_Flags::get( $module );
|
||||
if ( 'verify' !== $current ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => "Module must be in 'verify' state. Current: {$current}",
|
||||
'verified' => false,
|
||||
);
|
||||
}
|
||||
|
||||
$ok = $migration->verify_counts();
|
||||
|
||||
if ( ! $ok ) {
|
||||
// Verification failed — allow retry via dual_write → backfill.
|
||||
self::transition( $module, 'dual_write' );
|
||||
return array(
|
||||
'status' => 'dual_write',
|
||||
'message' => 'Verification failed. Rolled back to dual_write for retry.',
|
||||
'verified' => false,
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'status' => 'verify',
|
||||
'message' => 'Verification passed. Ready for cutover.',
|
||||
'verified' => true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cutover: switch reads to the custom table.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return array{status: string, message: string} Operation result.
|
||||
*/
|
||||
public static function cutover( string $module ): array {
|
||||
$current = TMDO_Feature_Flags::get( $module );
|
||||
|
||||
if ( 'verify' !== $current ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => "Module must be in 'verify' state. Current: {$current}",
|
||||
);
|
||||
}
|
||||
|
||||
self::transition( $module, 'cutover' );
|
||||
|
||||
return array(
|
||||
'status' => 'cutover',
|
||||
'message' => 'Cutover complete. Reads now come from the custom table. Run cleanup when ready.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback: return to idle state from any state.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return array{status: string, message: string} Operation result.
|
||||
*/
|
||||
public static function rollback( string $module ): array {
|
||||
$current = TMDO_Feature_Flags::get( $module );
|
||||
|
||||
if ( 'idle' === $current ) {
|
||||
return array(
|
||||
'status' => 'idle',
|
||||
'message' => 'Module is already idle.',
|
||||
);
|
||||
}
|
||||
|
||||
TMDO_Feature_Flags::reset( $module );
|
||||
|
||||
return array(
|
||||
'status' => 'idle',
|
||||
'message' => "Module rolled back from '{$current}' to idle.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup: stop writing to native postmeta.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return array{status: string, message: string} Operation result.
|
||||
*/
|
||||
public static function cleanup( string $module ): array {
|
||||
$current = TMDO_Feature_Flags::get( $module );
|
||||
|
||||
if ( 'cutover' !== $current ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => "Module must be in 'cutover' state. Current: {$current}",
|
||||
);
|
||||
}
|
||||
|
||||
self::transition( $module, 'cleanup' );
|
||||
|
||||
return array(
|
||||
'status' => 'cleanup',
|
||||
'message' => 'Cleanup started. Native postmeta writes are stopped. Run enable to complete.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable: mark migration fully complete.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return array{status: string, message: string} Operation result.
|
||||
*/
|
||||
public static function enable( string $module ): array {
|
||||
$current = TMDO_Feature_Flags::get( $module );
|
||||
|
||||
if ( 'cleanup' !== $current ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => "Module must be in 'cleanup' state. Current: {$current}",
|
||||
);
|
||||
}
|
||||
|
||||
self::transition( $module, 'complete' );
|
||||
|
||||
return array(
|
||||
'status' => 'complete',
|
||||
'message' => 'Module fully enabled. All reads and writes use the custom table.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive status for a module.
|
||||
*
|
||||
* @param string $module Module identifier.
|
||||
* @return array{module: string, state: string, zone: string, record: ?array} Module status.
|
||||
*/
|
||||
public static function status( string $module ): array {
|
||||
$migration = self::get_migration( $module );
|
||||
$record = $migration ? $migration->get_record() : null;
|
||||
|
||||
return array(
|
||||
'module' => $module,
|
||||
'state' => TMDO_Feature_Flags::get( $module ),
|
||||
'zone' => $migration ? $migration->get_zone() : '',
|
||||
'record' => $record,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,628 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform migration tool: WPDO v1->v2 legacy post meta migration
|
||||
/**
|
||||
* TMDO_Post_Migration — Post entity migration core (v2.9.3).
|
||||
*
|
||||
* Independent of the user-side TMDO_Migration_Orchestrator. The user
|
||||
* orchestrator is intentionally frozen (1105 lines, hardcoded ENTITY_TYPE='user'
|
||||
* via `self::ENTITY_TYPE` const) — this class implements the equivalent
|
||||
* post-side flow without touching any user code path.
|
||||
*
|
||||
* Phase coverage (compared to user 10-phase orchestrator):
|
||||
* diagnose ✓ implemented
|
||||
* backup ✗ skipped — v2.9.0 postmeta-cleanup CLI handles garbage;
|
||||
* full wp_postmeta backup deferred to v2.9.4 (admin tab
|
||||
* + DB hook) since wp_postmeta tends to be very large
|
||||
* (29k+ rows on dev10) and requires streaming approach
|
||||
* demote ✗ not applicable — post mode starts at 'disabled', no
|
||||
* aeav_only state to demote from
|
||||
* install_schema ✗ already done in v2.9.1 by Schema_Manager auto-create
|
||||
* backfill_bulk ✓ implemented (per-group, by post_type filter)
|
||||
* backfill_unserialize ✗ deferred to v2.9.4 (json groups: attachment +
|
||||
* nav_menu_item have only ~200 rows on dev10)
|
||||
* promote_shadow ✓ implemented (set_mode dual_write → shadow_read)
|
||||
* verify_sample ✓ implemented (sample-and-compare)
|
||||
* promote_aeav ✓ implemented (set_mode → aeav_only)
|
||||
* cleanup ✓ implemented (DELETE managed wp_postmeta keys)
|
||||
*
|
||||
* 🔒 v2.9.x frozen contract: this class must NEVER touch wp_usermeta,
|
||||
* wp_users, or any wp_wpdo_user_* table. All operations target wp_posts /
|
||||
* wp_postmeta / wp_wpdo_post_*.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.9.3
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Internal migration: $flat_table goes through Schema_Manager::sanitize_column_name + TMDO_Entity_Registry; $columns_sql/$cases_sql/$update_sql composed from the same sanitized sources; user-controlled values use prepare() placeholders. Multi-statement IN clauses with array_fill('%s') trigger false positives.
|
||||
|
||||
/**
|
||||
* Post entity migration core. Static API mirrors TMDO_Migration_Orchestrator
|
||||
* for predictability, but each method is post-only.
|
||||
*/
|
||||
final class TMDO_Post_Migration {
|
||||
|
||||
private const ENTITY_TYPE = 'post';
|
||||
|
||||
/**
|
||||
* Read-only inspection — what's the current post EAV state?
|
||||
*
|
||||
* @return array{
|
||||
* posts:int,
|
||||
* postmeta:int,
|
||||
* ratio:float,
|
||||
* mode:string,
|
||||
* groups:array<string,array{keys:string[],eav_rows:int,flat_rows:int,post_type:string|null}>
|
||||
* }
|
||||
*/
|
||||
public static function diagnose(): array {
|
||||
global $wpdb;
|
||||
|
||||
$posts_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts}" );
|
||||
$postmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta}" );
|
||||
$ratio = $posts_count > 0 ? round( $postmeta_count / $posts_count, 2 ) : 0.0;
|
||||
|
||||
$groups = array();
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
|
||||
$keys = TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group );
|
||||
$post_type = self::group_post_type( $group );
|
||||
$eav_rows = $keys ? self::count_eav_residue( $keys, $post_type ) : 0;
|
||||
|
||||
$flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group );
|
||||
$flat_rows = TMDO_Schema_Manager::table_exists( $flat_table )
|
||||
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
: 0;
|
||||
|
||||
$groups[ $group ] = array(
|
||||
'keys' => $keys,
|
||||
'eav_rows' => $eav_rows,
|
||||
'flat_rows' => $flat_rows,
|
||||
'post_type' => $post_type,
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'posts' => $posts_count,
|
||||
'postmeta' => $postmeta_count,
|
||||
'ratio' => $ratio,
|
||||
'mode' => TMDO_Mode_Manager::get( self::ENTITY_TYPE ),
|
||||
'groups' => $groups,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk SQL pivot for one group: select managed keys from wp_postmeta,
|
||||
* filter by post_type, pivot via MAX(CASE) GROUP BY post_id, UPSERT
|
||||
* into the group's flat table.
|
||||
*
|
||||
* @param string $group_name Entity group name (e.g. 'wc_product').
|
||||
* @return array{migrated:int,group:string,post_type:string|null}
|
||||
* @throws InvalidArgumentException When group is not registered.
|
||||
* @throws RuntimeException When the pivot SQL fails.
|
||||
*/
|
||||
public static function backfill_group( string $group_name ): array {
|
||||
global $wpdb;
|
||||
|
||||
$fields = TMDO_Entity_Registry::get_group_fields( self::ENTITY_TYPE, $group_name );
|
||||
if ( empty( $fields ) ) {
|
||||
throw new InvalidArgumentException(
|
||||
'Unknown post entity group: ' . esc_html( $group_name )
|
||||
);
|
||||
}
|
||||
|
||||
$post_type = self::group_post_type( $group_name );
|
||||
$flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name );
|
||||
|
||||
// Introspect target table columns so we only pivot fields that actually
|
||||
// exist in the flat schema. Defends against partial schema environments
|
||||
// (e.g. v2.9.3 deployed before Schema_Manager auto-create has run, or
|
||||
// custom installs that intentionally pruned columns).
|
||||
$existing_cols = self::get_existing_columns( $flat_table );
|
||||
if ( empty( $existing_cols ) ) {
|
||||
throw new RuntimeException(
|
||||
'Flat table missing or has no columns: ' . esc_html( $flat_table )
|
||||
);
|
||||
}
|
||||
|
||||
// Build column list and CASE expressions.
|
||||
// Skip json/textarea types from the bulk pivot — those need row-by-row
|
||||
// unserialize handling (deferred to v2.9.4 backfill_unserialize phase).
|
||||
$columns = array();
|
||||
$cases = array();
|
||||
$update_parts = array();
|
||||
foreach ( $fields as $field ) {
|
||||
$type = $field['type'] ?? 'text';
|
||||
if ( 'json' === $type ) {
|
||||
continue;
|
||||
}
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
||||
if ( ! isset( $existing_cols[ $col ] ) ) {
|
||||
continue; // Column not present in this table — skip silently.
|
||||
}
|
||||
$key = esc_sql( (string) $field['key'] );
|
||||
$columns[] = "`{$col}`";
|
||||
$cases[] = "MAX(CASE WHEN meta_key = '{$key}' THEN meta_value END) AS `{$col}`";
|
||||
$update_parts[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
|
||||
}
|
||||
|
||||
if ( empty( $columns ) ) {
|
||||
return array(
|
||||
'migrated' => 0,
|
||||
'group' => $group_name,
|
||||
'post_type' => $post_type,
|
||||
);
|
||||
}
|
||||
|
||||
$columns_sql = implode( ', ', $columns );
|
||||
$cases_sql = implode( ', ', $cases );
|
||||
$update_sql = implode( ', ', $update_parts );
|
||||
$post_type_filter = $post_type ? $wpdb->prepare( 'AND p.post_type = %s', $post_type ) : '';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
$rows = $wpdb->query(
|
||||
"INSERT INTO `{$flat_table}` (post_id, {$columns_sql})
|
||||
SELECT pm.post_id, {$cases_sql}
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE 1=1 {$post_type_filter}
|
||||
GROUP BY pm.post_id
|
||||
ON DUPLICATE KEY UPDATE {$update_sql}"
|
||||
);
|
||||
|
||||
if ( false === $rows ) {
|
||||
throw new RuntimeException(
|
||||
'backfill_group SQL failed: ' . esc_html( (string) $wpdb->last_error )
|
||||
);
|
||||
}
|
||||
|
||||
// MySQL ON DUPLICATE KEY UPDATE counts changes as 2 per affected row;
|
||||
// $rows = 2 * matched if pure update; for our migrate use case we just
|
||||
// want to confirm the operation completed. Re-count flat table rows
|
||||
// limited to $post_type for a clean migrated count.
|
||||
$count_sql = $post_type
|
||||
? $wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT pm.post_id) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = %s",
|
||||
$post_type
|
||||
)
|
||||
: "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta}";
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$migrated = (int) $wpdb->get_var( $count_sql );
|
||||
|
||||
return array(
|
||||
'migrated' => $migrated,
|
||||
'group' => $group_name,
|
||||
'post_type' => $post_type,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote post mode along the safe transition path.
|
||||
*
|
||||
* Path: disabled → dual_write → shadow_read → aeav_only.
|
||||
* Must be invoked separately for each step (caller decides timing).
|
||||
*
|
||||
* @param string $target_mode One of TMDO_Mode_Manager::MODE_* constants.
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public static function set_mode( string $target_mode ) {
|
||||
return TMDO_Mode_Manager::set( self::ENTITY_TYPE, $target_mode );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup: DELETE managed keys from wp_postmeta. Only callable when
|
||||
* post mode is aeav_only — otherwise EAV is still authoritative source.
|
||||
*
|
||||
* @return array{deleted:int}
|
||||
* @throws RuntimeException When mode != aeav_only.
|
||||
*/
|
||||
public static function cleanup(): array {
|
||||
$mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
|
||||
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
|
||||
throw new RuntimeException(
|
||||
'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only'
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$keys = self::get_managed_keys();
|
||||
if ( empty( $keys ) ) {
|
||||
return array( 'deleted' => 0 );
|
||||
}
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
||||
$deleted = (int) $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
"DELETE FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})",
|
||||
...$keys
|
||||
)
|
||||
);
|
||||
|
||||
return array( 'deleted' => $deleted );
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-by-row backfill for groups containing json-typed fields (v2.10.4).
|
||||
*
|
||||
* Bulk SQL pivot in backfill_group() can't handle json/serialized values
|
||||
* because the conversion serialize() → wp_json_encode() requires PHP-level
|
||||
* processing. This method delegates to TMDO_Entity_Migration_Engine which
|
||||
* already handles safe_unserialize and json encoding row-by-row.
|
||||
*
|
||||
* Idempotent — uses checkpoint cursor; safe to re-run.
|
||||
*
|
||||
* @param string $group_name Entity group name (e.g. 'attachment', 'nav_menu_item').
|
||||
* @param array $options Optional: batch_size (default 500), sleep_ms (50),
|
||||
* resume (true), dry_run (false).
|
||||
* @return array Engine result tuple including migrated/errors/skipped.
|
||||
*/
|
||||
public static function backfill_group_json( string $group_name, array $options = array() ): array {
|
||||
if ( ! class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
|
||||
return array( 'error' => 'TMDO_Entity_Migration_Engine class not available' );
|
||||
}
|
||||
return TMDO_Entity_Migration_Engine::migrate_group(
|
||||
self::ENTITY_TYPE,
|
||||
$group_name,
|
||||
$options
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Legacy zone-table cutover (v2.9.5)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Non-destructive copy of a legacy `wpdo_hot_<post_type>` zone table
|
||||
* into the new `wp_wpdo_post_<group>` flat table.
|
||||
*
|
||||
* Copies the intersection of columns (by name), excluding `id` and
|
||||
* `updated_at` so the flat table manages those itself. Uses ON DUPLICATE
|
||||
* KEY UPDATE so the operation is idempotent — re-running is safe.
|
||||
*
|
||||
* The legacy table is left UNTOUCHED — this is critical for v3.0.0 rollback
|
||||
* safety. The legacy table is only DROP'd at v3.0.0 release after several
|
||||
* release cycles of the new flat table being authoritative.
|
||||
*
|
||||
* @param string $post_type Post type the legacy table targets.
|
||||
* @param string $hot_table Fully-qualified legacy table name.
|
||||
* @param string $flat_table Fully-qualified target flat table name.
|
||||
* @return array{copied:int,common_columns:string[],post_type:string}
|
||||
* @throws RuntimeException When tables missing or copy SQL fails.
|
||||
*/
|
||||
public static function copy_legacy_hot_table(
|
||||
string $post_type,
|
||||
string $hot_table,
|
||||
string $flat_table
|
||||
): array {
|
||||
global $wpdb;
|
||||
|
||||
$hot_cols = self::get_existing_columns( $hot_table );
|
||||
$flat_cols = self::get_existing_columns( $flat_table );
|
||||
|
||||
if ( empty( $hot_cols ) ) {
|
||||
throw new RuntimeException(
|
||||
'Legacy hot table missing or empty: ' . esc_html( $hot_table )
|
||||
);
|
||||
}
|
||||
if ( empty( $flat_cols ) ) {
|
||||
throw new RuntimeException(
|
||||
'Target flat table missing: ' . esc_html( $flat_table )
|
||||
);
|
||||
}
|
||||
|
||||
// Intersect columns by name, excluding ones the flat table manages itself.
|
||||
$skip = array( 'id', 'updated_at', 'created_at' );
|
||||
$common = array();
|
||||
foreach ( $hot_cols as $name => $_ ) {
|
||||
if ( in_array( $name, $skip, true ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( ! isset( $flat_cols[ $name ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$common[] = $name;
|
||||
}
|
||||
|
||||
// post_id is the unique key — must always be present.
|
||||
if ( ! in_array( 'post_id', $common, true ) ) {
|
||||
throw new RuntimeException(
|
||||
'Cannot copy: post_id column not present in both tables'
|
||||
);
|
||||
}
|
||||
|
||||
$cols_quoted = '`' . implode( '`, `', $common ) . '`';
|
||||
$update_parts = array();
|
||||
foreach ( $common as $col ) {
|
||||
if ( 'post_id' === $col ) {
|
||||
continue;
|
||||
}
|
||||
$update_parts[] = "`{$col}` = VALUES(`{$col}`)";
|
||||
}
|
||||
$update_sql = implode( ', ', $update_parts );
|
||||
|
||||
$rows = $wpdb->query(
|
||||
"INSERT INTO `{$flat_table}` ({$cols_quoted})
|
||||
SELECT {$cols_quoted} FROM `{$hot_table}`
|
||||
ON DUPLICATE KEY UPDATE {$update_sql}"
|
||||
);
|
||||
|
||||
if ( false === $rows ) {
|
||||
throw new RuntimeException(
|
||||
'copy_legacy_hot_table SQL failed: ' . esc_html( (string) $wpdb->last_error )
|
||||
);
|
||||
}
|
||||
|
||||
// MySQL counts UPSERT modified rows differently from inserted rows;
|
||||
// re-count flat table by post_id intersection for clean number.
|
||||
$copied = (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$flat_table}` f
|
||||
INNER JOIN `{$hot_table}` h ON h.post_id = f.post_id"
|
||||
);
|
||||
|
||||
return array(
|
||||
'copied' => $copied,
|
||||
'common_columns' => $common,
|
||||
'post_type' => $post_type,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify legacy cutover by row count + sampled value comparison.
|
||||
*
|
||||
* Note: only checks row count + post_id presence. Per-column value
|
||||
* comparison would require knowing both tables' column types and
|
||||
* applying lossy/lossless conversion rules — out of scope for v2.9.5
|
||||
* (the COPY operation itself uses INSERT...SELECT which preserves
|
||||
* values byte-for-byte where types match).
|
||||
*
|
||||
* @param string $hot_table Legacy hot table name.
|
||||
* @param string $flat_table Target flat table name.
|
||||
* @return array{hot_rows:int,flat_rows:int,mismatched_rows:int,ok:bool}
|
||||
*/
|
||||
public static function verify_legacy_cutover( string $hot_table, string $flat_table ): array {
|
||||
global $wpdb;
|
||||
|
||||
$hot_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hot_table}`" );
|
||||
$flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" );
|
||||
|
||||
// Find post_ids in hot but not in flat (i.e. failed copies).
|
||||
$mismatched = (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$hot_table}` h
|
||||
LEFT JOIN `{$flat_table}` f ON f.post_id = h.post_id
|
||||
WHERE f.post_id IS NULL"
|
||||
);
|
||||
|
||||
return array(
|
||||
'hot_rows' => $hot_rows,
|
||||
'flat_rows' => $flat_rows,
|
||||
'mismatched_rows' => $mismatched,
|
||||
'ok' => 0 === $mismatched && $flat_rows >= $hot_rows,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Query Router benchmark (v2.10.2)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compare query latency between wp_postmeta JOIN path and flat-table path
|
||||
* for a single meta_key/value/compare combination.
|
||||
*
|
||||
* Both queries are functionally equivalent but use different storage:
|
||||
* postmeta path → INNER JOIN wp_postmeta (the legacy WP behavior)
|
||||
* flat path → INNER JOIN wp_wpdo_post_<group> (the v2.10.1 router target)
|
||||
*
|
||||
* Speed-up = postmeta_avg_ms / flat_avg_ms. Higher is better.
|
||||
*
|
||||
* @param string $post_type WP post_type to filter by.
|
||||
* @param string $meta_key Meta key to query.
|
||||
* @param string $compare Comparison operator (=, !=, <, <=, >, >=, LIKE).
|
||||
* @param string $value Value to compare against.
|
||||
* @param string $flat_table Fully qualified flat table name (must contain $meta_key column).
|
||||
* @param int $samples Number of times to run each query (default 50).
|
||||
* @return array{
|
||||
* samples:int,
|
||||
* postmeta_avg_ms:float,
|
||||
* flat_avg_ms:float,
|
||||
* speedup:float,
|
||||
* postmeta_rows:int,
|
||||
* flat_rows:int,
|
||||
* meta_key:string,
|
||||
* post_type:string,
|
||||
* }
|
||||
* @throws InvalidArgumentException When $samples <= 0 or compare invalid.
|
||||
* @throws RuntimeException When flat table doesn't exist.
|
||||
*/
|
||||
public static function benchmark_query(
|
||||
string $post_type,
|
||||
string $meta_key,
|
||||
string $compare,
|
||||
string $value,
|
||||
string $flat_table,
|
||||
int $samples = 50
|
||||
): array {
|
||||
if ( $samples <= 0 ) {
|
||||
$msg = 'Samples must be > 0, got ' . $samples;
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
|
||||
$allowed_compare = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'LIKE' );
|
||||
if ( ! in_array( strtoupper( $compare ), $allowed_compare, true ) ) {
|
||||
$msg = 'Invalid compare: ' . $compare . '. Allowed: ' . implode( ', ', $allowed_compare );
|
||||
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
||||
}
|
||||
|
||||
// Verify flat table exists.
|
||||
$existing = self::get_existing_columns( $flat_table );
|
||||
if ( empty( $existing ) ) {
|
||||
throw new RuntimeException(
|
||||
'Benchmark target flat table missing: ' . esc_html( $flat_table )
|
||||
);
|
||||
}
|
||||
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||
if ( ! isset( $existing[ $col ] ) ) {
|
||||
throw new RuntimeException(
|
||||
'Flat table does not have column for ' . esc_html( $meta_key )
|
||||
);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
// Build both query templates (parameterized via prepare in the loop).
|
||||
$pm_sql = $wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
|
||||
INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
|
||||
WHERE p.post_type = %s AND pm.meta_key = %s AND pm.meta_value {$compare} %s",
|
||||
$post_type,
|
||||
$meta_key,
|
||||
$value
|
||||
);
|
||||
|
||||
$flat_sql = $wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
|
||||
INNER JOIN `{$flat_table}` f ON f.post_id = p.ID
|
||||
WHERE p.post_type = %s AND f.`{$col}` {$compare} %s",
|
||||
$post_type,
|
||||
$value
|
||||
);
|
||||
|
||||
// Warm up MySQL query cache so the first run isn't penalized.
|
||||
$wpdb->get_var( $pm_sql );
|
||||
$wpdb->get_var( $flat_sql );
|
||||
|
||||
$pm_total = 0.0;
|
||||
$flat_total = 0.0;
|
||||
$pm_rows = 0;
|
||||
$flat_rows = 0;
|
||||
|
||||
for ( $i = 0; $i < $samples; $i++ ) {
|
||||
$start = microtime( true );
|
||||
$pm_rows = (int) $wpdb->get_var( $pm_sql );
|
||||
$pm_total += microtime( true ) - $start;
|
||||
|
||||
$start = microtime( true );
|
||||
$flat_rows = (int) $wpdb->get_var( $flat_sql );
|
||||
$flat_total += microtime( true ) - $start;
|
||||
}
|
||||
|
||||
$pm_avg_ms = ( $pm_total / $samples ) * 1000;
|
||||
$flat_avg_ms = ( $flat_total / $samples ) * 1000;
|
||||
$speedup = $flat_avg_ms > 0 ? ( $pm_avg_ms / $flat_avg_ms ) : 0.0;
|
||||
|
||||
return array(
|
||||
'samples' => $samples,
|
||||
'postmeta_avg_ms' => round( $pm_avg_ms, 3 ),
|
||||
'flat_avg_ms' => round( $flat_avg_ms, 3 ),
|
||||
'speedup' => round( $speedup, 2 ),
|
||||
'postmeta_rows' => $pm_rows,
|
||||
'flat_rows' => $flat_rows,
|
||||
'meta_key' => $meta_key,
|
||||
'post_type' => $post_type,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* All meta_keys registered for any post entity group.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function get_managed_keys(): array {
|
||||
$keys = array();
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
|
||||
$keys = array_merge( $keys, TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group ) );
|
||||
}
|
||||
return array_values( array_unique( $keys ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Map group name to the post_type it targets. The mapping is canonical
|
||||
* to v2.9.1 group definitions in TMDO_Post_Fields.
|
||||
*
|
||||
* Returns null for cross-post_type groups (e.g. 'wp_core').
|
||||
*
|
||||
* @param string $group Entity group name.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function group_post_type( 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of column_name → true for every column that exists in $table.
|
||||
* Returns empty array if table missing.
|
||||
*
|
||||
* @param string $table Fully qualified table name.
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
private static function get_existing_columns( string $table ): array {
|
||||
global $wpdb;
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`", ARRAY_A );
|
||||
if ( empty( $rows ) ) {
|
||||
return array();
|
||||
}
|
||||
$cols = array();
|
||||
foreach ( $rows as $r ) {
|
||||
$cols[ (string) ( $r['Field'] ?? '' ) ] = true;
|
||||
}
|
||||
return $cols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count wp_postmeta rows matching $keys, optionally filtered by post_type.
|
||||
*
|
||||
* @param string[] $keys Meta keys to match.
|
||||
* @param string|null $post_type Optional post_type filter.
|
||||
* @return int
|
||||
*/
|
||||
private static function count_eav_residue( array $keys, ?string $post_type = null ): int {
|
||||
global $wpdb;
|
||||
if ( empty( $keys ) ) {
|
||||
return 0;
|
||||
}
|
||||
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
||||
if ( $post_type ) {
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
"SELECT COUNT(*) FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})",
|
||||
$post_type,
|
||||
...$keys
|
||||
)
|
||||
);
|
||||
}
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})",
|
||||
...$keys
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
/**
|
||||
* Zone B (Warm) migration for postmeta to KV warm table.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone B (Warm) migration — postmeta → wpdo_warm KV table with TTL.
|
||||
*
|
||||
* Reads registered warm fields from Schema Registry,
|
||||
* copies their postmeta values into the warm table with optional TTL.
|
||||
*/
|
||||
class TMDO_Warm_Migration extends TMDO_Migration_Base {
|
||||
|
||||
/**
|
||||
* Returns the module identifier.
|
||||
*
|
||||
* @return string Module name.
|
||||
*/
|
||||
public function get_module(): string {
|
||||
return 'warm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the zone identifier.
|
||||
*
|
||||
* @return string Zone name.
|
||||
*/
|
||||
public function get_zone(): string {
|
||||
return 'warm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of warm meta rows to migrate.
|
||||
*
|
||||
* @return int Total row count.
|
||||
*/
|
||||
protected function count_source(): int {
|
||||
global $wpdb;
|
||||
|
||||
$meta_keys = $this->get_meta_keys();
|
||||
if ( empty( $meta_keys ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta is a core property.
|
||||
return (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})", // phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Warm migration: postmeta → Zone B path.
|
||||
...$meta_keys
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates one batch of warm meta rows.
|
||||
*
|
||||
* @param int $offset Starting row offset.
|
||||
* @return int Number of rows processed.
|
||||
*/
|
||||
protected function migrate_batch( int $offset ): int {
|
||||
global $wpdb;
|
||||
|
||||
$meta_keys = $this->get_meta_keys();
|
||||
if ( empty( $meta_keys ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$registry = TMDO_Schema_Registry::instance();
|
||||
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
|
||||
|
||||
$args = array_merge( $meta_keys, array( self::BATCH_SIZE, $offset ) );
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta is a core property.
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT post_id, meta_key, meta_value
|
||||
FROM {$wpdb->postmeta}
|
||||
WHERE meta_key IN ({$placeholders})
|
||||
ORDER BY meta_id ASC
|
||||
LIMIT %d OFFSET %d",
|
||||
...$args
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
|
||||
if ( empty( $rows ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$field = $registry->get_warm_field( $row['meta_key'] );
|
||||
$ttl = $field['ttl'] ?? null;
|
||||
|
||||
TMDO_Zone_Warm::set(
|
||||
(int) $row['post_id'],
|
||||
$row['meta_key'],
|
||||
$row['meta_value'],
|
||||
$ttl
|
||||
);
|
||||
}
|
||||
|
||||
return count( $rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the warm table row count is at least as large as the source.
|
||||
*
|
||||
* @return bool True if verification passes.
|
||||
*/
|
||||
public function verify_counts(): bool {
|
||||
global $wpdb;
|
||||
|
||||
$source = $this->count_source();
|
||||
$table = TMDO_Zone_Warm::table();
|
||||
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Warm::table()
|
||||
|
||||
return $target >= $source;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all registered warm meta_keys.
|
||||
*
|
||||
* @return array List of meta key strings.
|
||||
*/
|
||||
private function get_meta_keys(): array {
|
||||
$fields = TMDO_Schema_Registry::instance()->get_zone_fields( 'warm' );
|
||||
return array_column( $fields, 'meta_key' );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user