*/ 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} */ 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 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; } }