Files
2meet-data-optimizer/includes/migration/class-tmdo-post-migration.php
T
wpdev f524ea3f16 build: 移植 PHPCS + PHPStan L6 品質 gate(PR-I 前半)
- phpcs.xml(自 A 移植):ruleset 改名、*/tools/* 例外換成 */back-compat/*、
  中文註解全域排除 Squiz.Commenting.InlineComment.InvalidEndChar、
  interface 別名檔排除 OneObjectStructurePerFile
- 檔頭正規化:16 個檔案的 declare(strict_types=1) 與前導 // 註解移到
  file docblock 之後,並移除 <?php 後多餘空行(phpcbf 另自動修 190 處)
- phpstan.neon + .phpstan/stubs.php(TMDO_ 與 WPDO_ 兩套常數)+
  重新產生的 phpstan-baseline.neon(710 errors,A 的 3877 行 baseline
  因前綴與路徑不同無法沿用)

現況:PHPCS 0 errors / 0 warnings、PHPStan L6 No errors、
unit 451 / integration 398 GREEN

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:22:00 +08:00

632 lines
23 KiB
PHP

<?php
/**
* 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
*/
// phpcs:ignore WPDO.AntiEAV -- platform migration tool: WPDO v1->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<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
)
);
}
}