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; $post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s ORDER BY RAND() LIMIT %d", $post_type, $sample_size ) ); $sampled = count( $post_ids ); $matched = 0; $diffs = 0; $missing_flat = 0; $missing_postmeta = 0; if ( 0 === $sampled ) { return array( 'sampled' => 0, 'matched' => 0, 'diffs' => 0, 'missing_flat' => 0, 'missing_postmeta' => 0, 'group' => $group_name, 'post_type' => $post_type, ); } foreach ( $post_ids as $post_id ) { $post_id = (int) $post_id; foreach ( $keys as $key ) { $col = self::sanitize_column( $key ); $flat_val = $wpdb->get_var( $wpdb->prepare( "SELECT `{$col}` FROM `{$flat_table}` WHERE post_id = %d LIMIT 1", $post_id ) ); $pm_val = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1", $post_id, $key ) ); $flat_present = null !== $flat_val && '' !== $flat_val; $pm_present = null !== $pm_val && '' !== $pm_val; if ( ! $flat_present && ! $pm_present ) { // Both empty β€” neither side has the value, count as match // (key truly absent for this post). ++$matched; continue; } if ( ! $flat_present && $pm_present ) { ++$missing_flat; self::log_diff( $post_id, $key, (string) $pm_val, '(missing)' ); continue; } if ( $flat_present && ! $pm_present ) { ++$missing_postmeta; self::log_diff( $post_id, $key, '(missing)', (string) $flat_val ); continue; } // Loose equality β€” flat may have widened types (e.g. tinyintβ†’bigint) // or full-precision decimals (10,2 β†’ 18,6). if ( self::values_loose_equal( $pm_val, $flat_val ) ) { ++$matched; } else { ++$diffs; self::log_diff( $post_id, $key, (string) $pm_val, (string) $flat_val ); } } } return array( 'sampled' => $sampled, 'matched' => $matched, 'diffs' => $diffs, 'missing_flat' => $missing_flat, 'missing_postmeta' => $missing_postmeta, 'group' => $group_name, 'post_type' => $post_type, ); } /** * Cron handler: runs sample_compare for each registered group when post * mode is shadow_read. No-op otherwise. * * @return void */ public static function cron_tick(): void { if ( ! class_exists( 'TMDO_Mode_Manager' ) ) { return; } if ( 'shadow_read' !== TMDO_Mode_Manager::get( 'post' ) ) { return; } if ( ! class_exists( 'TMDO_Entity_Registry' ) ) { return; } global $wpdb; foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) { $keys = TMDO_Entity_Registry::get_group_keys( 'post', $group ); if ( empty( $keys ) ) { continue; } $post_type = self::group_post_type( $group ); if ( null === $post_type ) { continue; // wp_core spans all types, skip in cron tick. } $flat_table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group ); try { self::sample_compare( $post_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE ); } catch ( \Throwable $e ) { if ( class_exists( 'TMDO_Logger' ) ) { TMDO_Logger::error( 'post_shadow_verifier', 'cron_tick', $e->getMessage() ); } } } } /** * Recent diff records for entity_type='post'. * * @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(); } return TMDO_Shadow_Diff_Logger::recent( $limit, 'post' ); } /** * Aggregate diff stats for entity_type='post' over recent N hours. * * @param int $hours Window in hours (default 24). * @return array{total: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, 'by_key' => array(), ); } $since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) ); $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'post' AND ts >= %s", $since ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, COUNT(*) AS n FROM `{$table}` WHERE entity_type = 'post' 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' => $total, 'by_key' => $by_key, ); } // ───────────────────────────────────────────────────────────────────────── // 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 ); } /** * Map entity group β†’ primary post_type (null = cross-cutting like wp_core). * * @param string $group Entity group name. * @return string|null */ private static function group_post_type( string $group ): ?string { switch ( $group ) { case 'attachment': return 'attachment'; case 'wc_product': return 'product'; case 'hp_listing_core': return 'hp_listing'; case 'hp_request_core': return 'hp_request'; case 'hp_vendor_core': return 'hp_vendor'; case 'nav_menu_item': return 'nav_menu_item'; case 'wp_core': default: return null; } } /** * Loose equality for verifier β€” handles widened types (decimal precision, * tinyintβ†’bigint, etc.) and serialization-format differences without * false-positive divergence reports. * * Layers (fail-fast on first match): * 1. Identical strings * 2. Numeric loose match (handles "50" vs "50.000000") * 3. v2.10.5: Decoded match for serialized vs JSON values * (postmeta uses PHP serialize, flat tables use wp_json_encode for * json-typed fields β€” same logical data, different storage format) * * @param mixed $a Side A value. * @param mixed $b Side B value. * @return bool */ private static function values_loose_equal( $a, $b ): bool { // Both null/empty already filtered out by caller. $as = (string) $a; $bs = (string) $b; if ( $as === $bs ) { return true; } // Numeric loose match (handles "50" vs "50.000000"). if ( is_numeric( $as ) && is_numeric( $bs ) ) { return (float) $as === (float) $bs; } // v2.10.5: try decoded comparison for serialized/JSON values. $a_decoded = self::decode_value( $as ); $b_decoded = self::decode_value( $bs ); if ( ( null !== $a_decoded || null !== $b_decoded ) && $a_decoded === $b_decoded ) { return true; } return false; } /** * Best-effort decode of a string value: try PHP unserialize (object-safe), * then JSON. Returns the decoded structure, or null when neither succeeds * (so the caller can short-circuit instead of false-positive matching * against a literal "null" string). * * Returns null in three cases: * - Both decoders failed (input is plain non-encoded string) * - unserialize returned NULL legitimately (input was 'N;') * - Empty string * * The caller distinguishes these by combining with an identical-string * fast-path that runs first; non-encoded strings hit equality before this * decoder runs. * * @param string $s Raw string value. * @return mixed|null Decoded value, or null on failure. */ private static function decode_value( string $s ) { if ( '' === $s ) { return null; } // PHP serialize: 'a:N:{...}' / 'O:N:"...":...' / 'i:N;' / 's:N:"..."' / 'b:0|1;' / 'N;' / 'd:N;'. if ( preg_match( '/^[aOidsbN]:/', $s ) || 'N;' === $s ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors,Generic.PHP.NoSilencedErrors,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes=false hardens against object injection; @ swallows malformed-payload notices. $v = @unserialize( $s, array( 'allowed_classes' => false ) ); if ( false !== $v || 'b:0;' === $s ) { return $v; } } // JSON: arrays/objects start with [ or {. $first = $s[0] ?? ''; if ( '[' === $first || '{' === $first ) { $v = json_decode( $s, true ); if ( null !== $v ) { return $v; } } return null; } /** * Record a divergence via the shared shadow diff logger. * * @param int $post_id Post ID. * @param string $key Meta key. * @param string $pm_val Postmeta value (or '(missing)'). * @param string $flat_val Flat value (or '(missing)'). * @return void */ private static function log_diff( int $post_id, string $key, string $pm_val, string $flat_val ): void { if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) { return; } // Use the public record() method if the logger exposes one; otherwise // fall through silently. v2.5.x exposes compare_and_log() which does // internal compare; for verifier we already know they diverged so we // write directly to the table. global $wpdb; $table = $wpdb->prefix . 'wpdo_shadow_diffs'; $exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); if ( ! $exists ) { return; } $wpdb->insert( $table, array( 'entity_type' => 'post', 'entity_id' => $post_id, 'meta_key' => $key, 'postmeta_value' => substr( $pm_val, 0, 1000 ), 'zone_value' => substr( $flat_val, 0, 1000 ), 'diff_hash' => md5( $pm_val . '|' . $flat_val ), 'ts' => gmdate( 'Y-m-d H:i:s' ), ) ); } }