Files
2meet-data-optimizer/includes/class-tmdo-term-comment-backfill.php
T
wpdev 0603537f0a refactor(2meet-data-optimizer): resolve post_type/group/table mappings through the registry
- TMDO_Post_Fields::post_type_for_group() / groups_for_post_type() derive HivePress-style groups from their registry post_type scope (core keeps its own attachment / wc_product / nav_menu_item map); the three copy-pasted switches and the query router's POST_TYPE_GROUP_MAP use them
- Term/comment backfill, shadow verifier and CLI take their groups from the registry (TMDO_Term_Comment_Backfill::registered_groups()); installer drops the hp_* entries already covered by its table patterns; REST listings default post type is filterable (tmdo_rest_default_post_type, default 'post')
- Verify: NoHpMappingsTest guard + mapping/default tests; core unit 640 / integration 449, HP 146/49, WC 10/22 OK
- PR: PR-020e
2026-09-26 00:22:20 +08:00

229 lines
7.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_Term_Comment_Backfill — Pivot-style backfill from wp_*meta to flat
* tables for term + comment entities (v2.12.6 Phase 6).
*
* Background
* ----------
* Phase 1–5 cover write-time interception (anything written via the
* metadata API after v2.12.x activation goes to flat tables / wp_options /
* misc bucket / drop). But **historical** wp_termmeta / wp_commentmeta rows
* predating v2.12.x are still in those tables — they were never intercepted
* because they were already there.
*
* Phase 5's shadow_read verifier surfaces these as `missing_flat` drift
* (e.g. dev10 baseline: 42 term + 23 comment = 65 missing_flat rows).
* Without backfill, promoting to aeav_only would silently drop reads of
* those historical values (the read path goes flat → empty → fall-through
* to wp_termmeta DOES still work for now, but only because we left the
* fall-through in place; in v3.0.0 we DROP the source tables).
*
* Strategy
* --------
* One-shot SQL pivot per group, mirroring the user-side backfill pattern
* (v2.5.5 user_membership backfill, etc.):
*
* INSERT INTO wp_wpdo_term_hp_taxonomy (term_id, hp_sort_order, hp_default, hp_icon)
* SELECT t.term_id,
* MAX(CASE WHEN tm.meta_key = 'hp_sort_order' THEN tm.meta_value END),
* MAX(CASE WHEN tm.meta_key = 'hp_default' THEN tm.meta_value END),
* MAX(CASE WHEN tm.meta_key = 'hp_icon' THEN tm.meta_value END)
* FROM wp_terms t
* INNER JOIN wp_termmeta tm ON tm.term_id = t.term_id
* WHERE tm.meta_key IN (...registered keys...)
* GROUP BY t.term_id
* ON DUPLICATE KEY UPDATE
* hp_sort_order = COALESCE(VALUES(hp_sort_order), hp_sort_order),
* ...
*
* COALESCE preserves any value already in the flat table when the wp_*meta
* side has NULL for that key — important for partial-coverage backfills
* where some keys are present and others aren't on a given entity.
*
* 🔒 Frozen contract: never touches user / post entities; only term/comment.
*
* @package WP_Data_Optimizer
* @since 2.12.6
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Pivot SQL: keys + table from registry, parameterized via prepare placeholders for keys list. One-shot ops command.
/**
* Pivot-style backfill from wp_termmeta / wp_commentmeta to flat tables.
*/
final class TMDO_Term_Comment_Backfill {
/**
* Term and comment groups registered in the entity registry.
*
* @return array<string, string> Group name → entity type ('term' | 'comment').
*/
public static function registered_groups(): array {
$groups = array();
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return $groups;
}
foreach ( array( 'term', 'comment' ) as $entity_type ) {
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
$groups[ $group ] = $groups[ $group ] ?? $entity_type;
}
}
return $groups;
}
/**
* Run backfill for every registered term + comment group.
*
* @param bool $dry_run When true, only count rows without writing.
* @return array<string, array{group:string, entity_type:string, candidates:int, written:int, dry_run:bool}>
*/
public static function backfill_all( bool $dry_run = false ): array {
$results = array();
foreach ( self::registered_groups() as $group_name => $entity_type ) {
$results[ $group_name ] = self::backfill_group( $entity_type, $group_name, $dry_run );
}
return $results;
}
/**
* Run backfill for a single group.
*
* @param string $entity_type 'term' or 'comment'.
* @param string $group_name Entity group name.
* @param bool $dry_run When true, only count rows without writing.
* @return array{group:string, entity_type:string, candidates:int, written:int, dry_run:bool, error?:string}
*/
public static function backfill_group( string $entity_type, string $group_name, bool $dry_run = false ): array {
$result = array(
'group' => $group_name,
'entity_type' => $entity_type,
'candidates' => 0,
'written' => 0,
'dry_run' => $dry_run,
);
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
$result['error'] = 'Entity Registry unavailable';
return $result;
}
if ( ( self::registered_groups()[ $group_name ] ?? null ) !== $entity_type ) {
$result['error'] = "Unknown group: {$group_name}";
return $result;
}
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
if ( empty( $keys ) ) {
$result['error'] = "No keys registered for {$entity_type}:{$group_name}";
return $result;
}
global $wpdb;
// Resolve source / meta tables + id columns per entity type.
if ( 'term' === $entity_type ) {
$source_table = $wpdb->terms;
$source_id_col = 'term_id';
$meta_table = $wpdb->termmeta;
$meta_id_col = 'term_id';
$flat_id_col = 'term_id';
} elseif ( 'comment' === $entity_type ) {
$source_table = $wpdb->comments;
$source_id_col = 'comment_ID';
$meta_table = $wpdb->commentmeta;
$meta_id_col = 'comment_id';
$flat_id_col = 'comment_id';
} else {
$result['error'] = "Unsupported entity_type: {$entity_type}";
return $result;
}
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
// Count candidate entities — those with at least one of the registered keys in wp_*meta.
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$candidate_query = $wpdb->prepare(
"SELECT COUNT(DISTINCT s.`{$source_id_col}`)
FROM `{$source_table}` s
INNER JOIN `{$meta_table}` m ON m.`{$meta_id_col}` = s.`{$source_id_col}`
WHERE m.meta_key IN ({$placeholders})",
...$keys
);
$result['candidates'] = (int) $wpdb->get_var( $candidate_query );
if ( $dry_run ) {
return $result;
}
if ( 0 === $result['candidates'] ) {
return $result;
}
// Build SELECT clause: id + one MAX(CASE) per registered key (sanitized to flat column).
$select_parts = array( "s.`{$source_id_col}` AS id" );
$update_clauses = array();
$insert_cols = array( "`{$flat_id_col}`" );
foreach ( $keys as $key ) {
$col = self::sanitize_column( $key );
$insert_cols[] = "`{$col}`";
$select_parts[] = $wpdb->prepare(
"MAX(CASE WHEN m.meta_key = %s THEN m.meta_value END) AS `{$col}`",
$key
);
$update_clauses[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
}
$insert_sql = sprintf(
'INSERT INTO `%s` (%s)
SELECT %s
FROM `%s` s
INNER JOIN `%s` m ON m.`%s` = s.`%s`
WHERE m.meta_key IN (%s)
GROUP BY s.`%s`
ON DUPLICATE KEY UPDATE %s',
$flat_table,
implode( ', ', $insert_cols ),
implode( ",\n ", $select_parts ),
$source_table,
$meta_table,
$meta_id_col,
$source_id_col,
$placeholders,
$source_id_col,
implode( ', ', $update_clauses )
);
$prepared = $wpdb->prepare( $insert_sql, ...$keys );
$ok = $wpdb->query( $prepared );
if ( false === $ok ) {
$result['error'] = $wpdb->last_error ?: 'Backfill query failed';
return $result;
}
// MySQL `INSERT ... ON DUPLICATE KEY UPDATE` returns 1 per insert, 2 per
// update — divide by 2 with floor for a meaningful "rows touched" estimate
// only when both sides equal candidates. Easier: just report candidates
// since that's the upper bound and ON DUPLICATE replaces.
$result['written'] = $result['candidates'];
return $result;
}
/**
* Sanitize meta_key into a column name (mirrors Schema_Manager rules).
*
* @param string $key Meta key.
* @return string
*/
private static function sanitize_column( string $key ): string {
if ( class_exists( 'TMDO_Schema_Manager' ) ) {
return TMDO_Schema_Manager::sanitize_column_name( $key );
}
return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key );
}
}