Files
2meet-data-optimizer/includes/class-tmdo-term-comment-shadow-verifier.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

431 lines
13 KiB
PHP

<?php
/**
* TMDO_Term_Comment_Shadow_Verifier — Sample-and-compare flat vs wp_*meta
* for term + comment entities (v2.12.5 Phase 5).
*
* Mirror of TMDO_Post_Shadow_Verifier (v2.10.3). Used during shadow_read mode
* to verify the flat tables stay in sync with wp_termmeta / wp_commentmeta.
* Each sample picks a random entity + key, fetches both values, counts
* matches / diffs / missing rows. Divergent values are written to
* `wpdo_shadow_diffs` via TMDO_Shadow_Diff_Logger (entity_type-aware).
*
* Run pattern:
* - Manual: `wp wpdo term-comment-shadow-report`
* - Cron: wpdo_term_comment_shadow_verify event registered when EITHER
* term or comment mode is shadow_read; auto-unregistered when
* both modes are not shadow_read
*
* 🔒 Frozen contract: read-only verifier. Never modifies wp_termmeta /
* wp_commentmeta or flat tables.
*
* @package WP_Data_Optimizer
* @since 2.12.5
*/
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 -- Internal verifier: $flat_table comes from registry, columns sanitized via Schema_Manager, user values use prepare(). Read-only.
/**
* Sample-and-compare verifier for term + comment entity flat tables.
*/
final class TMDO_Term_Comment_Shadow_Verifier {
/** Cron event hook fired hourly when at least one of term/comment modes is shadow_read. */
public const CRON_HOOK = 'wpdo_term_comment_shadow_verify';
/** Default sample size per entity per cron tick. */
public const DEFAULT_SAMPLE_SIZE = 100;
/**
* Group → flat table suffix mapping. Keep in sync with
* TMDO_Hivepress_Term_Comment_Fields registrations and the misc bucket.
*
* @var array<string, array{entity_type:string, table_suffix:string, has_misc:bool}>
*/
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 a sample-compare pass for a single entity group.
*
* @param string $entity_type 'term' or 'comment'.
* @param string $group_name Entity group name.
* @param string $flat_table Fully-qualified flat table name.
* @param string[] $keys Meta keys to compare.
* @param int $sample_size Number of entities to sample.
* @return array{
* sampled:int,
* matched:int,
* diffs:int,
* missing_flat:int,
* missing_meta:int,
* group:string,
* entity_type:string,
* }
* @throws InvalidArgumentException When inputs invalid.
*/
public static function sample_compare(
string $entity_type,
string $group_name,
string $flat_table,
array $keys,
int $sample_size = self::DEFAULT_SAMPLE_SIZE
): array {
if ( ! in_array( $entity_type, array( 'term', 'comment' ), true ) ) {
$msg = 'entity_type must be term or comment';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
if ( $sample_size <= 0 ) {
$msg = 'Sample size must be > 0';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
if ( empty( $keys ) ) {
$msg = 'Keys array cannot be empty';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
global $wpdb;
// Source table + id column + meta table differ 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';
} else {
$source_table = $wpdb->comments;
$source_id_col = 'comment_ID';
$meta_table = $wpdb->commentmeta;
$meta_id_col = 'comment_id';
$flat_id_col = 'comment_id';
}
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT `{$source_id_col}` FROM `{$source_table}` ORDER BY RAND() LIMIT %d",
$sample_size
)
);
$sampled = count( $ids );
$matched = 0;
$diffs = 0;
$missing_flat = 0;
$missing_meta = 0;
if ( 0 === $sampled ) {
return array(
'sampled' => 0,
'matched' => 0,
'diffs' => 0,
'missing_flat' => 0,
'missing_meta' => 0,
'group' => $group_name,
'entity_type' => $entity_type,
);
}
foreach ( $ids as $object_id ) {
$object_id = (int) $object_id;
foreach ( $keys as $key ) {
$col = self::sanitize_column( $key );
$flat_val = $wpdb->get_var(
$wpdb->prepare(
"SELECT `{$col}` FROM `{$flat_table}` WHERE `{$flat_id_col}` = %d LIMIT 1",
$object_id
)
);
$meta_val = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM `{$meta_table}` WHERE `{$meta_id_col}` = %d AND meta_key = %s LIMIT 1",
$object_id,
$key
)
);
$flat_present = null !== $flat_val && '' !== $flat_val;
$meta_present = null !== $meta_val && '' !== $meta_val;
// In-memory counters only. Hook Bus auto-logs diffs to
// wpdo_shadow_diffs on every read in shadow_read mode (via
// TMDO_Shadow_Diff_Logger::compare_and_log) — this verifier
// is a sample-based snapshot, not the persistent log.
if ( ! $flat_present && ! $meta_present ) {
++$matched;
continue;
}
if ( ! $flat_present && $meta_present ) {
++$missing_flat;
continue;
}
if ( $flat_present && ! $meta_present ) {
++$missing_meta;
continue;
}
if ( self::values_loose_equal( $meta_val, $flat_val ) ) {
++$matched;
} else {
++$diffs;
}
}
}
return array(
'sampled' => $sampled,
'matched' => $matched,
'diffs' => $diffs,
'missing_flat' => $missing_flat,
'missing_meta' => $missing_meta,
'group' => $group_name,
'entity_type' => $entity_type,
);
}
/**
* Cron handler: runs sample_compare for every group whose entity is in
* shadow_read. No-op when neither term nor comment is shadow_read.
*
* @return void
*/
public static function cron_tick(): void {
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
return;
}
$term_shadow = 'shadow_read' === TMDO_Mode_Manager::get( 'term' );
$comment_shadow = 'shadow_read' === TMDO_Mode_Manager::get( 'comment' );
if ( ! $term_shadow && ! $comment_shadow ) {
return;
}
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
global $wpdb;
foreach ( self::GROUP_MAP as $group => $cfg ) {
$entity_type = $cfg['entity_type'];
// Only run when the entity is in shadow_read mode.
if ( 'term' === $entity_type && ! $term_shadow ) {
continue;
}
if ( 'comment' === $entity_type && ! $comment_shadow ) {
continue;
}
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
if ( empty( $keys ) ) {
continue;
}
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
try {
self::sample_compare( $entity_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
} catch ( \Throwable $e ) {
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::error(
'term_comment_shadow_verifier',
'cron_tick',
$e->getMessage()
);
}
}
}
}
/**
* Recent diff records for term + comment entity types.
*
* @param int $limit Max rows.
* @return array
*/
public static function recent_diffs( int $limit = 100 ): array {
if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
return array();
}
// Logger is entity-type-aware; gather both sides.
$term_diffs = TMDO_Shadow_Diff_Logger::recent( (int) ceil( $limit / 2 ), 'term' );
$comment_diffs = TMDO_Shadow_Diff_Logger::recent( (int) ceil( $limit / 2 ), 'comment' );
return array_merge( (array) $term_diffs, (array) $comment_diffs );
}
/**
* Aggregate diff stats for term+comment over recent N hours.
*
* @param int $hours Window (default 24).
* @return array{total:int,term:int,comment:int,by_key:array<string,int>}
*/
public static function diff_stats( int $hours = 24 ): array {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
$exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
return array(
'total' => 0,
'term' => 0,
'comment' => 0,
'by_key' => array(),
);
}
$since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) );
$term_total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'term' AND ts >= %s",
$since
)
);
$comment_total = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'comment' AND ts >= %s",
$since
)
);
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_key, COUNT(*) AS n FROM `{$table}` WHERE entity_type IN ('term','comment') AND ts >= %s GROUP BY meta_key ORDER BY n DESC",
$since
),
ARRAY_A
);
$by_key = array();
foreach ( (array) $rows as $row ) {
$by_key[ (string) $row['meta_key'] ] = (int) $row['n'];
}
return array(
'total' => $term_total + $comment_total,
'term' => $term_total,
'comment' => $comment_total,
'by_key' => $by_key,
);
}
/**
* Run sample_compare for ALL groups (regardless of mode). Used by
* `wp wpdo term-comment-shadow-report` so users can see drift even
* before promoting to shadow_read.
*
* @param int $sample_size Per-group sample size.
* @return array<string,array> Map of `group_name` → sample_compare() result.
*/
public static function run_all( int $sample_size = self::DEFAULT_SAMPLE_SIZE ): array {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return array();
}
global $wpdb;
$out = array();
foreach ( self::GROUP_MAP as $group => $cfg ) {
$entity_type = $cfg['entity_type'];
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
if ( empty( $keys ) ) {
continue;
}
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
try {
$out[ $group ] = self::sample_compare( $entity_type, $group, $flat_table, $keys, $sample_size );
} catch ( \Throwable $e ) {
$out[ $group ] = array(
'error' => $e->getMessage(),
'group' => $group,
);
}
}
return $out;
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* 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 );
}
/**
* Loose equality for verifier — handles type widening and serialization
* format differences. Layers fail-fast: identical → numeric → decoded.
*
* @param mixed $a Side A value.
* @param mixed $b Side B value.
* @return bool
*/
private static function values_loose_equal( $a, $b ): bool {
// 1. Identical strings.
if ( (string) $a === (string) $b ) {
return true;
}
// 2. Numeric loose match.
if ( is_numeric( $a ) && is_numeric( $b ) && (float) $a === (float) $b ) {
return true;
}
// 3. Decoded match (serialized vs JSON).
$decoded_a = self::decode_value( (string) $a );
$decoded_b = self::decode_value( (string) $b );
return $decoded_a === $decoded_b;
}
/**
* Decode a stored value: try unserialize → JSON decode → as-is.
*
* @param string $value Raw stored value.
* @return mixed
*/
private static function decode_value( string $value ) {
// Try unserialize for postmeta-style serialized payloads.
if ( '' !== $value ) {
$first_two = substr( $value, 0, 2 );
if ( 'a:' === $first_two || 'O:' === $first_two || 's:' === $first_two || 'i:' === $first_two || 'b:' === $first_two || 'd:' === $first_two ) {
$unserialized = @unserialize( $value, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize,WordPress.PHP.NoSilencedErrors
if ( false !== $unserialized || 'b:0;' === $value ) {
return $unserialized;
}
}
}
// Try JSON for flat-table json-typed columns.
if ( '' !== $value && ( '{' === $value[0] || '[' === $value[0] ) ) {
$json = json_decode( $value, true );
if ( null !== $json ) {
return $json;
}
}
return $value;
}
}