Files
2meet-data-optimizer-woocom…/includes/class-tmdo-wc-term-count-filter.php
T
wpdev 47288db826 chore: initial snapshot of 2meet-data-optimizer-woocommerce-addon v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 05:06:36 +08:00

237 lines
7.8 KiB
PHP

<?php
// phpcs:ignore WPDO.AntiEAV -- platform integration with WooCommerce: term count cache transform
/**
* TMDO_WC_Term_Count_Filter — Reroute WooCommerce term count cache from
* wp_termmeta to wp_options (v2.12.3 Phase 3).
*
* Background
* ----------
* WooCommerce caches the number of products per term as a wp_termmeta row:
*
* wp_termmeta(term_id=5, meta_key='product_count_product_cat', meta_value='42')
*
* Each `product_count_<taxonomy>` 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_<term_id>_<md5(meta_key)>')
*
* 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
}
}