` row is a transient-like cache — WC * recalculates and writes whenever a product is added/removed from a term. * Pattern is identical to HivePress's per-post TTL cache anti-pattern that * v2.11.5 solved: the data is genuine cache, but storage location is wrong. * * Strategy * -------- * Mirror v2.11.5 `TMDO_Hivepress_Transient_Filter`. Intercept term metadata * writes/reads where meta_key starts with `product_count_` and route to * wp_options as native transient (no per-key TTL — WC manages its own * invalidation; we just provide indistinguishable storage). * * Translation key: * wp_options('_transient_wpdo_wc_termcount__') * * Namespacing by term_id keeps caches scoped to the right term; md5 of the * meta_key handles arbitrary-length taxonomy slugs (`product_count_my_long_taxonomy_with_many_chars`). * * Default: enabled. Toggle: `wpdo_wc_term_count_filter_enabled` option. * * 🔒 Lessons applied (v2.11.7): added to TMDO_Hook_Bus_Bridge::COEXIST_WHITELIST * so `wp wpdo conflict-scan` does not report a false positive. * * @package WP_Data_Optimizer * @since 2.12.3 */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Routes WooCommerce term count cache out of wp_termmeta into wp_options. */ final class TMDO_WC_Term_Count_Filter { /** Option toggle key. */ public const OPT_ENABLED = 'wpdo_wc_term_count_filter_enabled'; /** Prefix that identifies a WooCommerce term count cache row. */ public const PREFIX = 'product_count_'; /** Namespace prefix for translated wp_options entries. */ public const TRANSLATED_NAMESPACE = 'wpdo_wc_termcount_'; /** * Register the four metadata filters on `init` priority 5. * Idempotent — safe to call multiple times. * * @return void */ public static function init(): void { if ( ! self::is_enabled() ) { return; } // Filter priority 9 (before Hook Bus at 10), same as HivePress filter. add_filter( 'get_term_metadata', array( self::class, 'on_read' ), 9, 4 ); add_filter( 'add_term_metadata', array( self::class, 'on_add' ), 9, 5 ); add_filter( 'update_term_metadata', array( self::class, 'on_update' ), 9, 5 ); add_filter( 'delete_term_metadata', array( self::class, 'on_delete' ), 9, 5 ); } /** * Check the admin toggle. Defaults to enabled. * * @return bool */ public static function is_enabled(): bool { return (bool) get_option( self::OPT_ENABLED, '1' ); } /** * Test whether a meta_key is a WC term count cache row. * * @param mixed $meta_key Candidate meta_key. * @return bool */ public static function is_target_key( $meta_key ): bool { return is_string( $meta_key ) && str_starts_with( $meta_key, self::PREFIX ); } /** * Translate (term_id, meta_key) → namespaced wp_options option_name root. * * @param int $term_id Term ID. * @param string $meta_key Original `product_count_*` meta_key. * @return string Translated option_name root (without `_transient_` prefix). */ public static function translate_key( int $term_id, string $meta_key ): string { return self::TRANSLATED_NAMESPACE . $term_id . '_' . md5( $meta_key ); } /** * Filter callback: get_term_metadata. * * @param mixed $pre Filter accumulator (null at our priority). * @param int $object_id Term ID. * @param string $meta_key Meta key being read. * @param bool $single Whether single value was requested. * @return mixed */ public static function on_read( $pre, $object_id, $meta_key, $single ) { unset( $single ); if ( ! self::is_target_key( $meta_key ) ) { return $pre; } $translated = self::translate_key( (int) $object_id, (string) $meta_key ); $option_name = '_transient_' . $translated; $value = get_option( $option_name, null ); if ( null === $value ) { // Cache miss — fall through to wp_termmeta (back-compat). return $pre; } // WP unwraps [0] for single=true callers; return array of values. return array( $value ); } /** * Filter callback: add_term_metadata. * * @param mixed $check Filter accumulator (null at our priority). * @param int $object_id Term ID. * @param string $meta_key Meta key being written. * @param mixed $meta_value Value being written. * @param bool $unique Whether the unique flag was set (unused). * @return mixed */ public static function on_add( $check, $object_id, $meta_key, $meta_value, $unique ) { unset( $unique ); if ( ! self::is_target_key( $meta_key ) ) { return $check; } self::write_translated( (int) $object_id, (string) $meta_key, $meta_value ); return true; } /** * Filter callback: update_term_metadata. * * @param mixed $check Filter accumulator (null at our priority). * @param int $object_id Term ID. * @param string $meta_key Meta key being written. * @param mixed $meta_value Value being written. * @param mixed $prev_value Previous value scope (unused). * @return mixed */ public static function on_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) { unset( $prev_value ); if ( ! self::is_target_key( $meta_key ) ) { return $check; } self::write_translated( (int) $object_id, (string) $meta_key, $meta_value ); return true; } /** * Filter callback: delete_term_metadata. * * @param mixed $check Filter accumulator (null at our priority). * @param int $object_id Term ID. * @param string $meta_key Meta key being deleted. * @param mixed $meta_value Value-scoped delete (unused). * @param bool $delete_all Whether to delete from all objects (unused). * @return mixed */ public static function on_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) { unset( $meta_value, $delete_all ); if ( ! self::is_target_key( $meta_key ) ) { return $check; } $translated = self::translate_key( (int) $object_id, (string) $meta_key ); $option_name = '_transient_' . $translated; delete_option( $option_name ); return true; } /** * Internal: persist value to wp_options as a no-expiry transient. * * `autoload=no` because WC reads on demand only — never via wp_load_alloptions(). * * @param int $term_id Term ID. * @param string $meta_key Original `product_count_*` meta_key. * @param mixed $meta_value Value to store. * @return void */ private static function write_translated( int $term_id, string $meta_key, $meta_value ): void { $translated = self::translate_key( $term_id, $meta_key ); $option_name = '_transient_' . $translated; update_option( $option_name, $meta_value, false ); } /** * One-time migration helper: count `product_count_*` rows in wp_termmeta. * * @return int */ public static function count_legacy_termmeta_rows(): int { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta} WHERE meta_key LIKE 'product\\_count\\_%'" ); // phpcs:enable } /** * One-time migration: DELETE all historical `product_count_*` rows from * wp_termmeta. Future writes auto-route to wp_options. * * @return int Rows deleted. */ public static function purge_legacy_termmeta_rows(): int { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->query( "DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE 'product\\_count\\_%'" ); // phpcs:enable } }