diff --git a/2meet-data-optimizer-hivepress-addon.php b/2meet-data-optimizer-hivepress-addon.php index 5ef54fb..35cc1c6 100644 --- a/2meet-data-optimizer-hivepress-addon.php +++ b/2meet-data-optimizer-hivepress-addon.php @@ -3,7 +3,7 @@ * Plugin Name: 2meet Data Optimizer — Hivepress AddOn * Plugin URI: https://2meet.io/2meet-data-optimizer/hivepress * Description: HivePress + 12 擴充整合:13 adapter (core/reviews/bookings/messages/memberships/requests/favorites/statistics/tags/seo/social-links/blocks/marketplace) + 7 HPCT interceptor + 5 query interceptor + admin tab + CLI namespace - * Version: 0.2.0 + * Version: 0.3.0 * Requires at least: 6.0 * Tested up to: 6.9.4 * Requires PHP: 8.1 @@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'TMDO_HIVEPRESS_VERSION', '0.2.0' ); +define( 'TMDO_HIVEPRESS_VERSION', '0.3.0' ); define( 'TMDO_HIVEPRESS_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_HIVEPRESS_URL', plugin_dir_url( __FILE__ ) ); define( 'TMDO_HIVEPRESS_FILE', __FILE__ ); diff --git a/CHANGELOG.md b/CHANGELOG.md index 03aae0e..f57af6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,41 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [0.3.0] — 2026-09-27 + +Released with 2meet-data-optimizer 1.2.0 (hardening 2.2). + +### Changed + +- With a persistent object cache (`wp_using_ext_object_cache()`), the + transient filter keeps HivePress's per-post transient pairs in the + `tmdo_hp_transient` cache group instead of `wp_options`, where they piled + up (1.15 M pairs on dev36). The cache is then the only store: rows written + before are neither read nor deleted (HivePress rebuilds those caches once), + because a `get_option()` miss adds the name to WordPress's `notoptions` for + good and a hit keeps the old row in the `options` cache group with no expiry. + Without an object cache nothing changes. After upgrading, remove the rows + left behind with core's `wp tmdo cleanup-hp-transients --options --confirm`. + +### Fixed + +- The detected / bound status cells of the HivePress admin table go through + `esc_html()` (2.1 audit); output unchanged. + +### Docs + +- `docs/REGISTERED-KEYS.md`: the six registered keys HivePress never writes + as meta, because its models alias them to core columns (listing + `hp_vendor` → `post_parent`, `hp_status` → `post_status`; request + `hp_status`, `hp_user`; vendor `hp_user`; review `hp_rating` → + `comment_karma`). The schema stays; their flat columns are always NULL. +- `docs/HIVEPRESS-COVERAGE.md`: dev36 inventory of the meta each HivePress + post type holds, where the extensions keep their data and the query shapes + the front end produces. Attribute auto-registration is not needed now: + every meta key HivePress queries is registered. + +--- + ## [0.2.0] — 2026-09-26 Released with 2meet-data-optimizer 1.1.0 (hardening 2.1, search-index mode). diff --git a/README.md b/README.md index 0cce256..2a5f5de 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ reviews / messages / memberships / requests / listing-meta --- +Registered keys HivePress keeps in core columns instead of meta (`hp_vendor`, `hp_status`, …): see [docs/REGISTERED-KEYS.md](docs/REGISTERED-KEYS.md). +What the HivePress extensions write and how they query it (dev36 inventory, D04 decision): see [docs/HIVEPRESS-COVERAGE.md](docs/HIVEPRESS-COVERAGE.md). + ## Requirements - WordPress ≥ 6.0 diff --git a/admin/class-tmdo-admin-hivepress.php b/admin/class-tmdo-admin-hivepress.php index ebe1c68..c1638a4 100644 --- a/admin/class-tmdo-admin-hivepress.php +++ b/admin/class-tmdo-admin-hivepress.php @@ -85,9 +85,9 @@ if ( ! class_exists( 'TMDO_Admin_HivePress' ) ) { - + - + diff --git a/docs/HIVEPRESS-COVERAGE.md b/docs/HIVEPRESS-COVERAGE.md new file mode 100644 index 0000000..bf7d8ee --- /dev/null +++ b/docs/HIVEPRESS-COVERAGE.md @@ -0,0 +1,61 @@ +# HivePress coverage: what the extensions write and how HivePress queries it + +Read-only inventory on **dev36** (2026-09-27): HivePress 1.7 with blocks, bookings, +favorites, marketplace, memberships, messages, requests, reviews, seo, social-links and +tags; 101,175 listings, 1,499 vendors, 709 requests. The data is seeded, so features the +seed never used show no rows — noted per line. Account pages (favorites, messages, +memberships) were not crawled (login). + +## Meta keys written (rows, `hp_*`) + +| Post type | Key | Rows | Registered group | Queried? | +|---|---|---|---|---| +| hp_listing | `hp_hourly_rate` | 101,157 | `hp_listing_core` | yes — filter BETWEEN, sort | +| hp_listing | `hp_price` | 100,437 | `hp_listing_core` | yes — filter BETWEEN, sort | +| hp_listing | `hp_rating`, `hp_rating_count` | 53,299 | `hp_listing_core` | `hp_rating` sort | +| hp_listing | `hp_verified` | 31,985 | `hp_listing_core` | no | +| hp_listing | `hp_featured` | 5,131 | `hp_listing_core` | yes — featured block (`= CHAR`, `meta_key` + `rand`) | +| hp_listing | `hp_warranty` | 100,401 | — | no (seed data: values are not warranty term ids; see below) | +| hp_vendor | `hp_rating`, `hp_hourly_rate`, `hp_rating_count`, `hp_verified` | 761–1,499 | `hp_vendor_core` | `hp_hourly_rate` filter, `hp_rating` sort | +| hp_vendor | `hp_availability` | 4 | — | no | +| hp_request | `hp_budget` | 709 | `hp_request_core` | yes — filter BETWEEN, `meta_value_num` sort | +| hp_request | `hp_due_date` | 709 | — | no | +| hp_booking | `hp_start_time`, `hp_end_time` | 1 | `hp_booking_core` | no (1 booking) | +| `*_attribute` | `hp_edit_field_*`, `hp_filterable`, … | 3 each | — | attribute settings, read by ID | + +User meta and comment meta: no `hp_*` rows. Term meta: `hp_sort_order` 170, `hp_icon` +153 (`hp_taxonomy`). + +## Where the extensions keep their data + +- **Reviews** — `hp_review` comments (5,324); rating in `comment_karma` (see + [REGISTERED-KEYS.md](REGISTERED-KEYS.md)). +- **Requests / offers** — `hp_request` posts; offers are `hp_offer` comments (48), no meta. +- **Messages** — `hp_message` comments (0 on dev36). +- **Favorites** — 4 user meta rows (no `hp_` prefix); nothing to flatten. +- **Bookings** — `hp_booking` posts (1); **memberships** — no plans on dev36. +- **Tags / categories / select attributes** — taxonomies (`hp_listing_tags` 2,160, + `hp_listing_category` 101,175, `hp_listing_warranty` 720 relationships). A select + filter (`warranty=28`) becomes `tax_query hp_listing_warranty IN`, never a meta query. +- **SEO, social links** — no rows on dev36. + +## Query shapes seen (front-end crawl, 18 URLs) + +- Search filters on number attributes: `hp_price` / `hp_hourly_rate` / `hp_budget` + `BETWEEN DECIMAL` — routed. +- Sort: the named shape `(NOT EXISTS OR EXISTS)` on `hp_price`, `hp_hourly_rate`, + `hp_rating` with `orderby=__order` — routed, flat-first since core 1.2.0; + requests sort by `meta_key=hp_budget` + `meta_value_num` (a secondary query here) — + a shape the router handles. +- Featured block: `meta_key=hp_featured` + `orderby=rand`, and `hp_featured = CHAR`. +- Every front-end search carries WooCommerce's `product_visibility NOT IN` tax query. +- `HivePress\Components\Attribute::set_search_query` runs its own `get_posts()` inside + `pre_get_posts` (suppress_filters): not routable (~100 ms on dev36). + +## D04 (auto-register HivePress attributes): not now + +Every meta key HivePress queries on dev36 is already registered; select attributes are +taxonomies; the unregistered keys that are written (`hp_due_date`, `hp_availability`, +`hp_warranty`) are never queried. Auto-registration would only pay off on a site whose +admin adds **filterable or sortable number / date attributes** — revisit then (a doctor +check listing such attributes without a registered key would flag it). diff --git a/docs/REGISTERED-KEYS.md b/docs/REGISTERED-KEYS.md new file mode 100644 index 0000000..c02c94c --- /dev/null +++ b/docs/REGISTERED-KEYS.md @@ -0,0 +1,38 @@ +# Registered keys HivePress never writes + +Some keys the AddOn registers in its flat groups are **aliases of core columns** in the +HivePress models: HivePress reads and writes the column, never a meta row. Their flat +columns therefore stay `NULL`, and a meta query on them finds nothing — natively and +routed alike. + +| Entity / group | Key | HivePress model field | Stored in | +|---|---|---|---| +| post / `hp_listing_core` | `hp_vendor` | Listing `vendor` | `wp_posts.post_parent` | +| post / `hp_listing_core` | `hp_status` | Listing `status` | `wp_posts.post_status` | +| post / `hp_request_core` | `hp_status` | Request `status` | `wp_posts.post_status` | +| post / `hp_request_core` | `hp_user` | Request `user` | `wp_posts.post_author` | +| post / `hp_vendor_core` | `hp_user` | Vendor `user` | `wp_posts.post_author` | +| comment / `hp_review` | `hp_rating` | Review `rating` (hivepress-reviews) | `wp_comments.comment_karma` | + +Sources: `'_alias' => …` in `hivepress/includes/models/class-listing.php`, +`class-vendor.php`, `hivepress-requests/includes/models/class-request.php` and +`hivepress-reviews/includes/models/class-review.php`. + +## Checked on dev36 (2026-09-27, 101,175 listings) + +- Native meta rows: `hp_vendor` 0, `hp_status` 0, `hp_user` 0, comment `hp_rating` 0. +- Instead: 101,175 / 101,175 listings have `post_parent > 0`; listing statuses are + `publish` 91,804 / `draft` 9,371; 5,244 of 5,324 `hp_review` comments carry + `comment_karma`. + +## What this means + +- Filters and sorts HivePress builds on these fields use the core columns, never + `meta_query`, so the router has nothing to route for them. +- Drift checks report nothing for them: native and flat are both empty. +- **No schema change** (plan decision D03, 2.2): the columns cost only NULLs, and + dropping them would need a flat-table migration on every site. + +Not in this list: `hp_expired_time` and `hp_featured_time` are real meta fields of the +Listing model (no alias); they are empty on dev36 only because its seeded listings +never expire or get featured. diff --git a/includes/class-tmdo-hivepress-transient-filter.php b/includes/class-tmdo-hivepress-transient-filter.php index 92c9fec..8adb2d5 100644 --- a/includes/class-tmdo-hivepress-transient-filter.php +++ b/includes/class-tmdo-hivepress-transient-filter.php @@ -77,6 +77,9 @@ final class TMDO_Hivepress_Transient_Filter { /** Namespace prefix for translated wp_options entries. */ public const TRANSLATED_NAMESPACE = 'wpdo_hp_pm_'; + /** Object cache group used instead of wp_options when a persistent cache is present. */ + public const CACHE_GROUP = 'tmdo_hp_transient'; + /** * Register the four metadata filters on `init`. Idempotent — safe to call * multiple times. @@ -164,7 +167,16 @@ final class TMDO_Hivepress_Transient_Filter { // HivePress cache layer reads the timeout *first*, then decides whether // to read the value. We must preserve raw stored values until HivePress // itself orders deletion. - $value = get_option( $option_name, null ); + if ( wp_using_ext_object_cache() ) { + // The object cache is the only store. Rows left in wp_options from + // before are not read (`wp tmdo cleanup-hp-transients --options` + // removes them): a get_option() miss would add the name to + // `notoptions` for good, as later writes never reach wp_options. + $value = wp_cache_get( $option_name, self::CACHE_GROUP ); + $value = false === $value ? null : $value; + } else { + $value = get_option( $option_name, null ); + } if ( null === $value ) { // Cache miss in our store → fall through to normal postmeta path @@ -239,16 +251,24 @@ final class TMDO_Hivepress_Transient_Filter { $translated = self::translate_key( (int) $object_id, (string) $meta_key ); $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; + if ( wp_using_ext_object_cache() ) { + wp_cache_delete( $option_name, self::CACHE_GROUP ); + return true; + } delete_option( $option_name ); return true; } /** - * Internal: persist value or timeout to wp_options. + * Internal: persist value or timeout to the object cache when a persistent + * one is in use (Redis: a TTL cache belongs there, not in wp_options), else + * to wp_options. * - * Both halves of the transient pair are written with `autoload=no` (they - * are read on-demand only — never via wp_load_alloptions() — so eagerly - * loading them on every page would defeat the point of the move). + * In wp_options both halves of the transient pair are written with + * `autoload=no` (they are read on-demand only — never via + * wp_load_alloptions() — so eagerly loading them on every page would defeat + * the point of the move). HivePress checks the timeout itself, so the + * cache entries get no TTL of their own. * * @param int $post_id Post ID. * @param string $meta_key Original `_transient_*` meta_key. @@ -260,6 +280,10 @@ final class TMDO_Hivepress_Transient_Filter { $translated = self::translate_key( $post_id, $meta_key ); $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; + if ( wp_using_ext_object_cache() ) { + wp_cache_set( $option_name, $meta_value, self::CACHE_GROUP ); + return; + } update_option( $option_name, $meta_value, false ); } diff --git a/tests/integration/HivepressTransientFilterTest.php b/tests/integration/HivepressTransientFilterTest.php index 827d907..3547f20 100644 --- a/tests/integration/HivepressTransientFilterTest.php +++ b/tests/integration/HivepressTransientFilterTest.php @@ -44,8 +44,10 @@ class HivepressTransientFilterTest extends TestCase { global $wpdb; $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); - // Reset wp_options state for filter tests - $GLOBALS['_wp_options'] = array(); + // Reset wp_options / object cache state for filter tests + $GLOBALS['_wp_options'] = array(); + $GLOBALS['_wp_cache'] = array(); + $GLOBALS['_wp_using_ext_object_cache'] = false; // Default the toggle to enabled update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '1' ); @@ -195,6 +197,46 @@ class HivepressTransientFilterTest extends TestCase { $this->assertSame( array( array( 'serialized', 'data' ) ), $value ); } + // ── External object cache (2.2 PR-081) ────────────────────────────────── + + public function test_with_an_object_cache_writes_skip_wp_options(): void { + $GLOBALS['_wp_using_ext_object_cache'] = true; + $key = '_transient_hp_models/cat/v1'; + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); + + $this->assertTrue( WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' ) ); + WPDO_Hivepress_Transient_Filter::on_update( null, 100, '_transient_timeout_hp_models/cat/v1', '99999999', '' ); + + $this->assertFalse( get_option( '_transient_' . $translated ), 'no wp_options row' ); + $this->assertFalse( get_option( '_transient_timeout_' . $translated ), 'no wp_options row' ); + $this->assertSame( array( 'val' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) ); + $this->assertSame( array( '99999999' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, '_transient_timeout_hp_models/cat/v1', true ) ); + } + + public function test_with_an_object_cache_a_value_left_in_wp_options_is_not_read(): void { + // A miss must not fall back to get_option(): the option is absent after + // cleanup and WordPress would add its name to `notoptions` for good. + $key = '_transient_hp_models/cat/v1'; + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); + update_option( '_transient_' . $translated, 'from-before' ); + $GLOBALS['_wp_using_ext_object_cache'] = true; + + $this->assertNull( WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) ); + } + + public function test_with_an_object_cache_delete_leaves_wp_options_alone(): void { + $key = '_transient_hp_models/cat/v1'; + $translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key ); + update_option( '_transient_' . $translated, 'from-before' ); + $GLOBALS['_wp_using_ext_object_cache'] = true; + WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' ); + + WPDO_Hivepress_Transient_Filter::on_delete( null, 100, $key, '', false ); + + $this->assertNull( WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) ); + $this->assertSame( 'from-before', get_option( '_transient_' . $translated ), 'left for cleanup-hp-transients --options' ); + } + // ── count/purge legacy ────────────────────────────────────────────────── public function test_count_legacy_postmeta_rows_returns_zero_when_clean(): void { diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index b34bf9d..c5be687 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -23,6 +23,13 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) { } require_once $tmdo_core_bootstrap; +// External object cache switch for the transient filter tests (Redis on real sites). +if ( ! function_exists( 'wp_using_ext_object_cache' ) ) { + function wp_using_ext_object_cache( $using = null ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + return ! empty( $GLOBALS['_wp_using_ext_object_cache'] ); + } +} + define( 'TMDO_HIVEPRESS_PATH', dirname( __DIR__, 2 ) . '/' ); define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' ); define( 'TMDO_HIVEPRESS_FILE', TMDO_HIVEPRESS_PATH . '2meet-data-optimizer-hivepress-addon.php' );