Files
2meet-data-optimizer/includes/class-tmdo-term-comment-backfill.php
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

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

227 lines
7.7 KiB
PHP
Raw Permalink 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 15 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 {
/**
* Group → flat table suffix mapping. Mirror of TMDO_Hivepress_Term_Comment_Fields.
*
* @var array<string, array{entity_type:string, table_suffix:string}>
*/
private const GROUP_MAP = array(
'hp_taxonomy' => array(
'entity_type' => 'term',
'table_suffix' => 'wpdo_term_hp_taxonomy',
),
'hp_review' => array(
'entity_type' => 'comment',
'table_suffix' => 'wpdo_comment_hp_review',
),
);
/**
* 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::GROUP_MAP as $group_name => $cfg ) {
$results[ $group_name ] = self::backfill_group( $cfg['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 ( ! isset( self::GROUP_MAP[ $group_name ] ) ) {
$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 = $wpdb->prefix . self::GROUP_MAP[ $group_name ]['table_suffix'];
// 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 );
}
}