76c01e44df
對齊 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
227 lines
7.7 KiB
PHP
227 lines
7.7 KiB
PHP
<?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 {
|
||
|
||
/**
|
||
* 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 );
|
||
}
|
||
}
|