d36bb954d1
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
629 lines
23 KiB
PHP
629 lines
23 KiB
PHP
<?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
|
|
)
|
|
);
|
|
}
|
|
}
|