v2 legacy post meta migration declare(strict_types=1); 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 * } */ 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_` zone table * into the new `wp_wpdo_post_` 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_ (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 */ 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 ) ); } }