26 Commits

Author SHA1 Message Date
wpdev d6fd831416 Merge pull request 'release 0.5.3: 2meet-data-optimizer-hivepress-addon' (#7) from release/0.5.3 into main
Tests / PHP Lint (push) Successful in 6s
Tests / Unit Tests (push) Successful in 1m6s
Tests / Integration Tests (push) Successful in 1m6s
2026-10-07 14:02:51 +08:00
wpdev 763f705ac1 fix(2meet-data-optimizer-hivepress-addon): 0.5.3 view-count flush keeps flat row and meta cache in sync
Tests / PHP Lint (pull_request) Successful in 6s
Tests / Unit Tests (pull_request) Successful in 53s
Tests / Integration Tests (pull_request) Successful in 1m13s
flush_views_to_postmeta() adds warm-zone views back with a direct UPDATE,
which the Hook Bus never sees: under dual_write/shadow_read the
hp_listing_core.hp_view_count column drifted after every hourly batch and
the (Redis) object cache kept the old count. After the UPDATE, drop the
post's meta cache and, while native is still the source of truth, rebuild
the entity's flat row with migrate_entity().
2026-10-07 10:37:57 +08:00
wpdev 5953a5ed3b fix(2meet-data-optimizer-hivepress-addon): 0.5.2 attribute bridge uses hp_{slug} meta keys
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:30:09 +08:00
wpdev 6909bf6a99 Merge pull request 'release 0.5.1: 2meet-data-optimizer-hivepress-addon' (#6) from release/0.5.1 into main
Tests / PHP Lint (push) Successful in 9s
Tests / Unit Tests (push) Successful in 1m1s
Tests / Integration Tests (push) Successful in 1m47s
2026-10-02 20:46:33 +08:00
wpdev 9803f8379d chore(2meet-data-optimizer-hivepress-addon): 0.5.1 repackage (no functional change)
Tests / PHP Lint (pull_request) Successful in 7s
Tests / Unit Tests (pull_request) Successful in 56s
Tests / Integration Tests (pull_request) Successful in 1m22s
Version bump for a re-release packaged alongside the WP-MVB v8 hub (dev39)
acceptance; code is identical to the previous release.
2026-10-02 17:23:21 +08:00
wpdev f7f9bc5cc4 Merge pull request 'hardening 2.4: 2meet-data-optimizer-hivepress-addon 0.5.0 (sort indexes, a day's transient TTL)' (#5) from hardening/2.4 into main
Tests / PHP Lint (push) Successful in 7s
Tests / Unit Tests (push) Successful in 1m24s
Tests / Integration Tests (push) Successful in 1m24s
2026-09-27 23:57:57 +08:00
wpdev bfe8912600 chore(2meet-data-optimizer-hivepress-addon): 0.5.0 release notes and version
Tests / PHP Lint (pull_request) Successful in 7s
Tests / Unit Tests (pull_request) Successful in 1m23s
Tests / Integration Tests (pull_request) Successful in 1m16s
- CHANGELOG: PR-119 (sort indexes ending with post_id), PR-120 (a day's TTL)
- Version 0.4.0 -> 0.5.0 (header, TMDO_HIVEPRESS_VERSION)
- PR: PR-125
2026-09-27 23:29:33 +08:00
wpdev 15bbf335f4 perf(2meet-data-optimizer-hivepress-addon): keep HivePress transients a day in the object cache
- MAX_CACHE_TTL: a week -> a day (HivePress's own default); a timeout still
  outlives its value by a minute
- dev36 (1.3M listings): 4.7M entries / 1 GB in the shared Redis at a week,
  evicting other sites' keys
- Verify: composer test (148), integration (53), phpcs clean on the file
- PR: PR-120
2026-09-27 23:26:08 +08:00
wpdev 0cd2a9db5b perf(2meet-data-optimizer-hivepress-addon): (column, post_id) indexes for the listing sorts HivePress offers
- hp_listing_core declares (hp_price, post_id), (hp_rating, post_id) and
  (hp_hourly_rate, post_id): with core 1.4.0 a flat-first price, rating or
  hourly-rate sort reads them in order instead of sorting every match
  (dev36 stage 3: the price-range + price-sort search was the slowest routed
  page, 2.9 s at 1.3M listings); core 1.3.0 ignores indexes ending with post_id
- the pinned schema hash is unchanged (indexes are not part of it)
- Verify: HivePressEntityGroupsTest, unit 148, integration 53, phpcs;
  dev36 front-end sorts
- PR: PR-119
2026-09-27 22:41:46 +08:00
wpdev d802bf0145 Merge pull request 'hardening 2.3: 2meet-data-optimizer-hivepress-addon 0.4.0 (listing indexes, transient TTLs, site attributes)' (#4) from hardening/2.3 into main
Tests / PHP Lint (push) Successful in 24s
Tests / Unit Tests (push) Successful in 1m15s
Tests / Integration Tests (push) Successful in 1m32s
2026-09-27 18:37:50 +08:00
wpdev df62edd0f5 docs(2meet-data-optimizer-hivepress-addon): add PR-115 to the 0.4.0 release notes
Tests / PHP Lint (pull_request) Successful in 7s
Tests / Unit Tests (pull_request) Successful in 1m21s
Tests / Integration Tests (pull_request) Successful in 1m12s
- timeout TTL order, attribute types and registration timing; Redis upgrade
  procedure gains the timeout pass
- PR: PR-102 (follow-up)
2026-09-27 17:34:19 +08:00
wpdev 69cd4b6f8b fix(2meet-data-optimizer-hivepress-addon): let transient timeouts outlive their values; register site attributes once themes load
- review F5: a timeout entry expired before its value (a day vs a week), and
  HivePress reads a missing timeout as "never expires", serving the stale
  value for up to six more days. The timeout now expires a minute after the
  later of MAX_CACHE_TTL and its own time, so HivePress sees it expire first
- review F7: site attributes accept only types with a plain index (json and
  binary would fail the CREATE TABLE)
- review F8: the hp_listing_attrs group is registered on the run after
  after_setup_theme (init:1), so a filter added in a theme counts and the
  group has one definition per request; only keys of other groups are skipped
- Verify: TTL test, attribute group tests, unit 148, integration 53, phpcs
- PR: PR-115 (2.3 acceptance correctness review)
2026-09-27 17:33:13 +08:00
wpdev c139ebca89 docs(2meet-data-optimizer-hivepress-addon): add PR-109 to the 0.4.0 release notes
- Added: site listing attributes through tmdo_hp_listing_attribute_fields
- PR: PR-102 (follow-up)
2026-09-27 17:12:54 +08:00
wpdev 0efac618de feat(2meet-data-optimizer-hivepress-addon): flatten a site's own listing attributes on opt-in
- the tmdo_hp_listing_attribute_fields filter (hp_ key => type) registers a
  site's HivePress listing attributes, such as a "Warranty" number attribute
  (hp_warranty, 901k rows on dev36), in their own hp_listing_attrs group: a
  new table with each column indexed at creation; hp_listing_core and its
  pinned hash are unchanged
- keys another group manages, keys without the hp_ prefix and unknown types
  are ignored
- Verify: HivePressEntityGroupsTest (opt-in, filtering, pinned hashes), unit
  147, integration 53, phpcs; dev36 hp_warranty backfill and queries
- PR: PR-109
2026-09-27 17:10:19 +08:00
wpdev e57dd5bd15 docs(2meet-data-optimizer-hivepress-addon): 0.4.0 release notes
- version 0.4.0; CHANGELOG for PR-090, 090b and 099 with dev36 numbers
- Upgrading: deploy with core 1.3.0; give 0.3.0 Redis entries a TTL with
  redis-cli --scan | EXPIRE ... NX | --pipe (tested on dev36: 3.0 M keys,
  45 s, latency max 2 ms), never flush-group
- Verify: composer test (145)
- PR: PR-102
2026-09-27 16:50:01 +08:00
wpdev b2585459e3 fix(2meet-data-optimizer-hivepress-addon): declare the listing indexes only on a core that adds them outside web requests
- the hp_listing_core indexes are declared only when TMDO_Schema_Manager has
  ddl_allowed() (core 1.3.0+); core 1.2.0 would run the ALTER on 900k rows
  inside the first request after deploy, a visitor's included, if this AddOn
  were deployed first
- Verify: unit 145, integration 53, phpcs
- PR: PR-090b (found while writing the 0.4.0 notes)
2026-09-27 16:50:01 +08:00
wpdev d129139c7d perf(2meet-data-optimizer-hivepress-addon): expire object-cache transients
- with an external object cache, a HivePress timeout entry expires when it
  records; the value (written before its timeout) after at most a week — a
  miss only makes HivePress rebuild it. Entries of deleted posts no longer
  accumulate without end
- Verify: HivepressTransientFilterTest TTL test, integration 53, unit 145;
  dev36 Redis TTL on tmdo_hp_transient keys
- PR: PR-099
2026-09-27 16:42:52 +08:00
wpdev f06a80eae5 perf(2meet-data-optimizer-hivepress-addon): index the numeric listing columns HivePress filters and sorts on
- hp_listing_core declares indexes on hp_hourly_rate and hp_rating_count; core
  adds them in place (WP-CLI / WP-Cron), the pinned schema hash is unchanged
- Verify: unit 145, integration 52, phpcs; dev36 902k listings (SHOW INDEX, EXPLAIN)
- PR: PR-090
2026-09-27 16:08:23 +08:00
wpdev 130678a8e6 Merge pull request 'hardening 2.2: 2meet-data-optimizer-hivepress-addon 0.3.0 (transients in the object cache)' (#3) from hardening/2.2 into main
Tests / PHP Lint (push) Successful in 38s
Tests / Integration Tests (push) Successful in 1m38s
Tests / Unit Tests (push) Successful in 1m38s
2026-09-27 10:56:52 +08:00
wpdev 788ddccfb3 fix(2meet-data-optimizer-hivepress-addon): with an object cache, keep HivePress transients out of wp_options entirely
Tests / PHP Lint (pull_request) Successful in 10s
Tests / Unit Tests (pull_request) Successful in 1m2s
Tests / Integration Tests (pull_request) Successful in 1m53s
- On a cache miss the filter fell back to get_option(): with the values now written to the cache, the option is absent, so WordPress added its name to `notoptions` for good (dev36: +1,000 entries for 1,000 posts, one array loaded on every miss); a hit kept the old row in the options cache group with no expiry, which slowed the selective Redis flush (8.9 s, BUSY). The cache is now the only store for reads and deletes; old rows wait for cleanup-hp-transients --options. Without an object cache nothing changes.
- Verify: HivepressTransientFilterTest (2 RED before); dev36 notoptions 210 → 210 over 1,000 posts (was 128 → 1,128)
- PR: PR-089c
2026-09-27 10:32:12 +08:00
wpdev c6faaebbc1 docs(2meet-data-optimizer-hivepress-addon): 0.3.0 release notes
- Bump to 0.3.0; CHANGELOG entry for PR-081–084, with the post-upgrade cleanup-hp-transients step
- Verify: composer test, integration
- PR: PR-087
2026-09-27 09:54:03 +08:00
wpdev ef7e005583 chore(2meet-data-optimizer-hivepress-addon): escape the admin status cells
- The detected / bound status cells of the HivePress admin table echo their literal marks through esc_html() (2.1 audit, Info 1, AddOn part); the output is unchanged.
- PR: PR-084
2026-09-27 09:20:27 +08:00
wpdev 99f4653b25 docs(2meet-data-optimizer-hivepress-addon): map HivePress extension meta keys and query shapes
- docs/HIVEPRESS-COVERAGE.md: read-only inventory on dev36 of the hp_* meta each post type holds (registered or not, queried or not), where the extensions keep their data (comments, taxonomies, user meta) and the query shapes a front-end crawl produced.
- Conclusion for plan D04: attribute auto-registration is not needed now — every meta key HivePress queries is registered, select attributes are taxonomies, and the unregistered keys written (hp_due_date, hp_availability, hp_warranty) are never queried; revisit for sites that add filterable or sortable number / date attributes. README links it.
- PR: PR-083
2026-09-27 09:19:09 +08:00
wpdev bab91ef6b9 docs(2meet-data-optimizer-hivepress-addon): document registered keys HivePress never writes
- docs/REGISTERED-KEYS.md lists the six registered keys that are aliases of core columns in the HivePress models (listing hp_vendor → post_parent, hp_status → post_status; request hp_status, hp_user → post_author; vendor hp_user; review hp_rating → comment_karma), with the dev36 evidence (0 meta rows each) and why the schema stays (plan D03). README links it.
- PR: PR-082
2026-09-27 09:15:20 +08:00
wpdev b515b8441e fix(2meet-data-optimizer-hivepress-addon): keep HivePress transients in the object cache when there is one
- With a persistent object cache (wp_using_ext_object_cache(), Redis on the HivePress sites) the transient filter stores HivePress's per-post transient pairs in the tmdo_hp_transient cache group instead of wp_options, where they piled up (1.15 M rows on dev36). Reads fall back to wp_options for values written before; deletes clear both. Without an object cache nothing changes.
- Verify: HivepressTransientFilterTest object-cache cases (RED before); dev36 with Redis: update/get/delete_post_meta on an HP transient key leaves 0 rows in wp_options and postmeta.
- PR: PR-081
2026-09-27 09:00:03 +08:00
wpdev fda9e43087 Merge pull request 'hardening 2.1: 2meet-data-optimizer-hivepress-addon 0.2.0 (staged rollout)' (#2) from hardening/2.1 into main
Tests / PHP Lint (push) Successful in 7s
Tests / Integration Tests (push) Failing after 1m15s
Tests / Unit Tests (push) Successful in 1m22s
2026-09-26 21:19:51 +08:00
15 changed files with 563 additions and 16 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer — Hivepress AddOn * Plugin Name: 2meet Data Optimizer — Hivepress AddOn
* Plugin URI: https://2meet.io/2meet-data-optimizer/hivepress * 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 * 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.5.3
* Requires at least: 6.0 * Requires at least: 6.0
* Tested up to: 6.9.4 * Tested up to: 6.9.4
* Requires PHP: 8.1 * Requires PHP: 8.1
@@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
define( 'TMDO_HIVEPRESS_VERSION', '0.2.0' ); define( 'TMDO_HIVEPRESS_VERSION', '0.5.3' );
define( 'TMDO_HIVEPRESS_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_HIVEPRESS_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_HIVEPRESS_URL', plugin_dir_url( __FILE__ ) ); define( 'TMDO_HIVEPRESS_URL', plugin_dir_url( __FILE__ ) );
define( 'TMDO_HIVEPRESS_FILE', __FILE__ ); define( 'TMDO_HIVEPRESS_FILE', __FILE__ );
+140
View File
@@ -11,6 +11,146 @@ Versioning follows [Semantic Versioning](https://semver.org/).
--- ---
## [0.5.3] — 2026-10-07 — 瀏覽數 flush 不再造成漂移
### Fixed
- **`TMDO_Listing_Stats::flush_views_to_postmeta()` 寫完後同步扁平表與快取**:每小時把 warm zone 的瀏覽數
用原子 `UPDATE … meta_value + N` 加回 `hp_view_count`,繞過了 meta API:Hook Bus 看不到這次寫入,
dual_write/shadow_read 下 `hp_listing_core.hp_view_count` 每批落後一次(夜間 drift-check 每晚 diff),
物件快取(Redis 下跨請求)也一直是舊值。現在 UPDATE 之後清掉該篇的 `post_meta` 快取,
並在原生仍是真相時(同時寫原生與扁平)用 `TMDO_Entity_Migration_Engine::migrate_entity()` 重建那一列。
`hp_view_count` 同時在 `hp_listing_core` 與 `hp_request_core`:只重建含這個 key、而且適用該篇 post type 的群組
(不帶 post type 的 `get_field()` 只回最後登記的那組)。aeav_only 不動(扁平才是真相)。
---
## [0.5.2] — 2026-10-05 — attribute bridge 的 meta key
### Fixed
- **`TMDO_HivePress_Attribute_Bridge` 組出正確的 meta key**:HivePress 把每個模型的屬性存成
`hp_{slug}`(例如 `hp_warranty`),不是 `hp_{model}_{slug}`。舊的 key 在資料庫裡不存在,
照它建議的欄位登記後永遠是空的。
---
## [0.5.1] — 2026-10-02 — 重新打包
### Changed
- 重新打包(無功能變更):與 WP-MVB v8 樞紐站(dev39)一同驗收後發版;程式碼與前一版相同。
---
## [0.5.0] — 2026-09-28
Released with 2meet-data-optimizer 1.4.0 (hardening 2.4).
### Changed
- `hp_listing_core` declares `(hp_price, post_id)`, `(hp_rating, post_id)` and
`(hp_hourly_rate, post_id)` indexes for the listing sorts HivePress offers:
with core 1.4.0 reading the ID tie-break from the flat `post_id`, a sort walks
the index and stops at the page instead of sorting every match (dev36, 1.3M
listings: price sort 14 s native → 0.37 s routed, hourly rate 11.5 s → 0.35 s,
rating 43 s → 0.51 s). Core 1.3.0 ignores indexes ending with `post_id`, so
nothing changes there. The pinned schema hash is unchanged. (PR-119)
- Transient entries in a persistent object cache live a day (`MAX_CACHE_TTL`,
HivePress's own default) instead of a week; a timeout still outlives its value
by a minute. At a week dev36 kept 4.7M entries (1 GB) in the shared Redis and
pushed other sites' keys out. Entries already cached keep their week. (PR-120)
---
## [0.4.0] — 2026-09-27
Released with 2meet-data-optimizer 1.3.0 (hardening 2.3).
### Changed
- `hp_listing_core` declares indexes on `hp_hourly_rate` and `hp_rating_count`
(HivePress filters and sorts on both). They are declared through the group's
`indexes`, not `searchable`, so the pinned schema hash is unchanged and the
table is not rebuilt; core 1.3.0 adds them in place from WP-CLI / WP-Cron.
dev36 (902k listings): two indexes in about 4.7 s; routed `rating_count IN`
2.1 s → 0.36 s, `rating_count !=` 2.1 s → 0.76 s, hourly rate + price sort
2.1 s → 0.65 s. (PR-090)
- With a persistent object cache, transient entries now expire: a value after
a week (`MAX_CACHE_TTL`; HivePress writes it before its timeout, and a miss
only makes it rebuild), its timeout a minute after the later of that and its
own time — HivePress reads a missing timeout as "never expires", so the
timeout must go last. Entries of deleted posts no longer pile up. (PR-099,
PR-115)
### Added
- Opt-in flattening of a site's own listing attributes: the
`tmdo_hp_listing_attribute_fields` filter (`'hp_warranty' => 'integer'`)
registers them in an `hp_listing_attrs` group — a new table, each column
indexed at creation — leaving `hp_listing_core` unchanged. Types: text,
textarea, integer, decimal, boolean, date, datetime, timestamp, enum. The
group is registered once themes load, so the filter may live in a theme;
core 1.3.0 keeps it native until a drift fix has filled it. (PR-109, PR-115)
### Upgrading
- Deploy with core 1.3.0: on an older core the AddOn declares no index at all
(core 1.2.0 would run the ALTER inside a web request). (PR-090b)
- Redis: entries written by 0.3.0 have no TTL (dev36 had 3.0 M). Give them one
with non-blocking commands — never `wp cache flush-group`, which the Redis
Object Cache drop-in runs as one server-side script and which blocked Redis
on a large group. `NX` leaves entries written by 0.4.0 alone:
```
redis-cli --scan --pattern '<WP_CACHE_KEY_SALT>wp:tmdo_hp_transient:*' \
| awk '{printf "EXPIRE %s 604800 NX\r\n", $0}' | redis-cli --pipe
redis-cli --scan --pattern '<WP_CACHE_KEY_SALT>wp:tmdo_hp_transient:_transient_timeout_*' \
| awk '{printf "EXPIRE %s 608400 GT\r\n", $0}' | redis-cli --pipe
```
The second pass makes timeouts outlive their values. dev36: 3,013,352 keys
in 45 s and 1,867,569 timeouts in 32 s, 0 errors, Redis latency max 2 ms. The pattern
follows the drop-in's key layout (`<salt><blog prefix>:<group>:<key>`);
check it with `redis-cli --scan --pattern '<salt>*tmdo_hp_transient*' | head`
first. redis-cli 7 has no `--count` with `--scan`.
---
## [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 ## [0.2.0] — 2026-09-26
Released with 2meet-data-optimizer 1.1.0 (hardening 2.1, search-index mode). Released with 2meet-data-optimizer 1.1.0 (hardening 2.1, search-index mode).
+3
View File
@@ -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 ## Requirements
- WordPress ≥ 6.0 - WordPress ≥ 6.0
+2 -2
View File
@@ -85,9 +85,9 @@ if ( ! class_exists( 'TMDO_Admin_HivePress' ) ) {
<tr> <tr>
<td><code><?php echo esc_html( $slug ); ?></code></td> <td><code><?php echo esc_html( $slug ); ?></code></td>
<td><?php echo esc_html( $name ); ?></td> <td><?php echo esc_html( $name ); ?></td>
<td><?php echo $is_detected ? '✅' : '⚪'; ?></td> <td><?php echo esc_html( $is_detected ? '✅' : '⚪' ); ?></td>
<td><?php echo esc_html( $detected[ $slug ] ?? '—' ); ?></td> <td><?php echo esc_html( $detected[ $slug ] ?? '—' ); ?></td>
<td><?php echo $is_bound ? '✅' : '—'; ?></td> <td><?php echo esc_html( $is_bound ? '✅' : '—' ); ?></td>
<td><?php echo null === $score ? '—' : esc_html( (string) $score ); ?></td> <td><?php echo null === $score ? '—' : esc_html( (string) $score ); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
+61
View File
@@ -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=<name>__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).
+38
View File
@@ -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.
@@ -76,6 +76,7 @@ final class TMDO_HivePress_Entity_Groups {
self::register_hp_request_core_group(); self::register_hp_request_core_group();
self::register_hp_vendor_core_group(); self::register_hp_vendor_core_group();
self::register_hp_user_group(); self::register_hp_user_group();
self::register_hp_listing_attributes_group();
} }
// ── Group definitions ──────────────────────────────────────────────────── // ── Group definitions ────────────────────────────────────────────────────
@@ -257,9 +258,66 @@ final class TMDO_HivePress_Entity_Groups {
'label' => 'Hourly rate', 'label' => 'Hourly rate',
), ),
), ),
array( 'post_types' => array( 'hp_listing' ) ) array(
'post_types' => array( 'hp_listing' ),
// Filtered and sorted on by HivePress search. Declared as indexes
// (added in place) rather than `searchable`, which would change
// the schema hash and rebuild a table that may hold aeav_only data.
// Only with core 1.3.0+ (ddl_allowed()): older cores run the ALTER
// inside whatever request comes first, a visitor's included.
'indexes' => method_exists( 'TMDO_Schema_Manager', 'ddl_allowed' ) ? array(
'hourly_rate' => array( 'hp_hourly_rate' ),
'rating_count' => array( 'hp_rating_count' ),
// HivePress sorts: the value then the ID tie-break, which core 1.4.0
// reads in order (flat-first) instead of sorting every match.
'price_id' => array( 'hp_price', 'post_id' ),
'rating_id' => array( 'hp_rating', 'post_id' ),
'hourly_rate_id' => array( 'hp_hourly_rate', 'post_id' ),
) : array(),
)
); );
} }
/**
* A site's own HivePress listing attributes (created under Listings →
* Attributes, e.g. a "Warranty" number → hp_warranty), opt-in through the
* `tmdo_hp_listing_attribute_fields` filter: `array( 'hp_warranty' => 'integer' )`.
*
* They get a group of their own — a new table, each column indexed at
* creation — so hp_listing_core and its pinned definition stay unchanged.
* Keys another group already manages, keys without the hp_ prefix and
* types that cannot take a plain index (json, binary) are ignored. Core
* fills the new table from native meta (drift fix) before using it.
*
* Registered once themes are loaded (the init:1 run): a filter added in a
* theme is seen, and the group gets one definition per request.
*
* @return void
*/
private static function register_hp_listing_attributes_group(): void {
if ( ! did_action( 'after_setup_theme' ) ) {
return;
}
$fields = array();
foreach ( (array) apply_filters( 'tmdo_hp_listing_attribute_fields', array() ) as $key => $type ) {
$key = (string) $key;
$owner = TMDO_Entity_Registry::get_field( 'post', $key, 'hp_listing' );
if ( 1 !== preg_match( '/^hp_[a-z0-9_]+$/', $key )
|| ! in_array( $type, array( 'text', 'textarea', 'integer', 'decimal', 'boolean', 'date', 'datetime', 'timestamp', 'enum' ), true )
|| ( null !== $owner && 'hp_listing_attrs' !== $owner['group'] ) ) {
continue;
}
$fields[] = array(
'key' => $key,
'type' => $type,
'searchable' => true,
'label' => 'Site listing attribute',
);
}
if ( $fields ) {
TMDO_Entity_Registry::register_group( 'post', 'hp_listing_attrs', $fields, array( 'post_types' => array( 'hp_listing' ) ) );
}
}
/** /**
* HivePress request core meta keys (post_type=hp_request). * HivePress request core meta keys (post_type=hp_request).
* *
@@ -77,6 +77,12 @@ final class TMDO_Hivepress_Transient_Filter {
/** Namespace prefix for translated wp_options entries. */ /** Namespace prefix for translated wp_options entries. */
public const TRANSLATED_NAMESPACE = 'wpdo_hp_pm_'; 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';
/** Longest an object-cache entry lives: a day, HivePress's own default (2.4 PR-120). */
public const MAX_CACHE_TTL = DAY_IN_SECONDS;
/** /**
* Register the four metadata filters on `init`. Idempotent — safe to call * Register the four metadata filters on `init`. Idempotent — safe to call
* multiple times. * multiple times.
@@ -164,7 +170,16 @@ final class TMDO_Hivepress_Transient_Filter {
// HivePress cache layer reads the timeout *first*, then decides whether // HivePress cache layer reads the timeout *first*, then decides whether
// to read the value. We must preserve raw stored values until HivePress // to read the value. We must preserve raw stored values until HivePress
// itself orders deletion. // 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 ) { if ( null === $value ) {
// Cache miss in our store → fall through to normal postmeta path // Cache miss in our store → fall through to normal postmeta path
@@ -239,16 +254,27 @@ final class TMDO_Hivepress_Transient_Filter {
$translated = self::translate_key( (int) $object_id, (string) $meta_key ); $translated = self::translate_key( (int) $object_id, (string) $meta_key );
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; $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 ); delete_option( $option_name );
return true; 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 * In wp_options both halves of the transient pair are written with
* are read on-demand only — never via wp_load_alloptions() — so eagerly * `autoload=no` (they are read on-demand only — never via
* loading them on every page would defeat the point of the move). * wp_load_alloptions() — so eagerly loading them on every page would defeat
* the point of the move). In the object cache the value, written before its
* timeout, expires after MAX_CACHE_TTL (a miss only makes HivePress rebuild
* it) and the timeout a minute after the later of that and its own time,
* so HivePress always sees it expire first; entries of deleted posts do
* not pile up (2.3 PR-099).
* *
* @param int $post_id Post ID. * @param int $post_id Post ID.
* @param string $meta_key Original `_transient_*` meta_key. * @param string $meta_key Original `_transient_*` meta_key.
@@ -260,6 +286,13 @@ final class TMDO_Hivepress_Transient_Filter {
$translated = self::translate_key( $post_id, $meta_key ); $translated = self::translate_key( $post_id, $meta_key );
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
if ( wp_using_ext_object_cache() ) {
// A timeout outlives its value: HivePress takes a missing timeout for
// "never expires" and would keep serving the value.
$ttl = $is_timeout ? max( self::MAX_CACHE_TTL, (int) $meta_value - time() ) + MINUTE_IN_SECONDS : self::MAX_CACHE_TTL;
wp_cache_set( $option_name, $meta_value, self::CACHE_GROUP, $ttl );
return;
}
update_option( $option_name, $meta_value, false ); update_option( $option_name, $meta_value, false );
} }
+36
View File
@@ -154,6 +154,8 @@ class TMDO_Listing_Stats {
// Seed the key when it does not exist yet. // Seed the key when it does not exist yet.
if ( ! $updated ) { if ( ! $updated ) {
add_post_meta( $post_id, 'hp_view_count', $new_views, true ); add_post_meta( $post_id, 'hp_view_count', $new_views, true );
} else {
self::resync_view_count( $post_id );
} }
// Remove the warm entry after flush. // Remove the warm entry after flush.
@@ -164,6 +166,40 @@ class TMDO_Listing_Stats {
return $flushed; return $flushed;
} }
/**
* The atomic UPDATE above bypasses the meta API: the object cache (persistent
* under Redis) keeps the old count, and the Hook Bus never sees the write, so in
* dual_write / shadow_read the flat column drifts by every flushed batch
* (drift-check diff on hp_view_count each night). Drop the cached meta and
* rebuild the entity's flat row from native — only while native is the source
* of truth (aeav_only keeps flat authoritative).
*
* @param int $post_id Listing post ID.
*/
private static function resync_view_count( int $post_id ): void {
wp_cache_delete( $post_id, 'post_meta' );
if ( ! class_exists( 'TMDO_Mode_Manager' ) || ! class_exists( 'TMDO_Entity_Registry' ) || ! class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
return;
}
if ( ! TMDO_Mode_Manager::writes_to_flat( 'post' ) || ! TMDO_Mode_Manager::writes_to_eav( 'post' ) ) {
return;
}
// hp_view_count lives in more than one group (hp_listing_core, hp_request_core):
// get_field() without a post type returns whichever registered last. Rebuild only
// the groups that hold the key and apply to this post's type.
$type = (string) get_post_type( $post_id );
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
$types = TMDO_Entity_Registry::get_group_post_types( 'post', $group );
if ( ! in_array( 'hp_view_count', TMDO_Entity_Registry::get_group_keys( 'post', $group ), true ) || ( $types && ! in_array( $type, $types, true ) ) ) {
continue;
}
TMDO_Entity_Migration_Engine::migrate_entity( 'post', $group, $post_id );
if ( class_exists( 'TMDO_Cache_Orchestrator' ) ) {
TMDO_Cache_Orchestrator::invalidate( 'post', $post_id, $group );
}
}
}
// ── Zone D: Expired Listing Archival ───────────────────────────────────── // ── Zone D: Expired Listing Archival ─────────────────────────────────────
/** /**
@@ -20,7 +20,7 @@
* Usage: * Usage:
* $bridge = new TMDO_HivePress_Attribute_Bridge(); * $bridge = new TMDO_HivePress_Attribute_Bridge();
* $candidates = $bridge->discover_attributes(); * $candidates = $bridge->discover_attributes();
* // returns: [['slug' => 'beds', 'type' => 'integer', 'filterable' => true, 'searchable' => true, 'meta_key' => 'hp_listing_beds'], ...] * // returns: [['slug' => 'beds', 'type' => 'integer', 'filterable' => true, 'searchable' => true, 'meta_key' => 'hp_beds'], ...]
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -123,7 +123,8 @@ if ( ! class_exists( 'TMDO_HivePress_Attribute_Bridge' ) ) {
return array( return array(
'model' => $model, 'model' => $model,
'slug' => $slug, 'slug' => $slug,
'meta_key' => sprintf( 'hp_%s_%s', $model, $slug ), // HivePress stores every model's attribute as hp_{slug} (e.g. hp_warranty), not hp_{model}_{slug}.
'meta_key' => 'hp_' . $slug,
'type' => (string) ( $cfg['edit_field']['type'] ?? $cfg['search_field']['type'] ?? 'text' ), 'type' => (string) ( $cfg['edit_field']['type'] ?? $cfg['search_field']['type'] ?? 'text' ),
'filterable' => (bool) ( $cfg['filterable'] ?? false ), 'filterable' => (bool) ( $cfg['filterable'] ?? false ),
'sortable' => (bool) ( $cfg['sortable'] ?? false ), 'sortable' => (bool) ( $cfg['sortable'] ?? false ),
+6
View File
@@ -24,6 +24,12 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) {
} }
require_once $tmdo_core_bootstrap; require_once $tmdo_core_bootstrap;
if ( ! function_exists( 'did_action' ) ) {
function did_action( string $hook ): int {
return (int) ( $GLOBALS['_wp_did_action'][ $hook ] ?? 0 );
}
}
define( 'TMDO_HIVEPRESS_PATH', dirname( __DIR__ ) . '/' ); define( 'TMDO_HIVEPRESS_PATH', dirname( __DIR__ ) . '/' );
define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' ); define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' );
define( 'TMDO_HIVEPRESS_FILE', dirname( __DIR__ ) . '/2meet-data-optimizer-hivepress-addon.php' ); define( 'TMDO_HIVEPRESS_FILE', dirname( __DIR__ ) . '/2meet-data-optimizer-hivepress-addon.php' );
@@ -44,8 +44,10 @@ class HivepressTransientFilterTest extends TestCase {
global $wpdb; global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
// Reset wp_options state for filter tests // Reset wp_options / object cache state for filter tests
$GLOBALS['_wp_options'] = array(); $GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cache'] = array();
$GLOBALS['_wp_using_ext_object_cache'] = false;
// Default the toggle to enabled // Default the toggle to enabled
update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '1' ); update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '1' );
@@ -195,6 +197,74 @@ class HivepressTransientFilterTest extends TestCase {
$this->assertSame( array( array( 'serialized', 'data' ) ), $value ); $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 ) );
}
/**
* Entries expire in the object cache too (2.3 PR-099): the value (written
* first, before its timeout is known) after a day, the timeout after the
* later of that and its own time, plus a minute. Orphans of deleted posts
* no longer pile up.
*/
public function test_with_an_object_cache_entries_get_a_ttl(): void {
// A day, HivePress's own default: at a week, 1.3M listings kept 4.7M
// entries (1 GB) in a shared Redis and pushed other sites' keys out (2.4 PR-120).
$this->assertSame( 86400, WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL );
$GLOBALS['_wp_using_ext_object_cache'] = true;
$key = '_transient_hp_models/cat/v1';
$translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key );
WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' );
WPDO_Hivepress_Transient_Filter::on_update( null, 100, '_transient_timeout_hp_models/cat/v1', (string) ( time() + 3600 ), '' );
WPDO_Hivepress_Transient_Filter::on_update( null, 101, '_transient_timeout_hp_models/cat/v1', (string) ( time() - 5 ), '' );
$ttl = $GLOBALS['_wp_cache_ttl'][ WPDO_Hivepress_Transient_Filter::CACHE_GROUP ];
$this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL, $ttl[ '_transient_' . $translated ] );
// A timeout must outlive its value: HivePress takes a missing timeout for
// "never expires" and would serve the stale value (review F5).
$this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $ttl[ '_transient_timeout_' . $translated ] );
$this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $ttl[ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 101, $key ) ], 'already expired' );
WPDO_Hivepress_Transient_Filter::on_update( null, 102, '_transient_timeout_hp_models/cat/v1', (string) ( time() + 2 * WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL ), '' );
$this->assertEqualsWithDelta( 2 * WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $GLOBALS['_wp_cache_ttl'][ WPDO_Hivepress_Transient_Filter::CACHE_GROUP ][ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 102, $key ) ], 2 );
}
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 ────────────────────────────────────────────────── // ── count/purge legacy ──────────────────────────────────────────────────
public function test_count_legacy_postmeta_rows_returns_zero_when_clean(): void { public function test_count_legacy_postmeta_rows_returns_zero_when_clean(): void {
+15
View File
@@ -21,8 +21,23 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) {
. dirname( __DIR__, 3 ) . '/2meet-data-optimizer' . dirname( __DIR__, 3 ) . '/2meet-data-optimizer'
); );
} }
// Defined before the core bootstrap (which only adds it when missing) so tests can read the TTL.
if ( ! function_exists( 'wp_cache_set' ) ) {
function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool {
$GLOBALS['_wp_cache'][ $group ][ $key ] = $value;
$GLOBALS['_wp_cache_ttl'][ $group ][ $key ] = (int) $ttl;
return true;
}
}
require_once $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_PATH', dirname( __DIR__, 2 ) . '/' );
define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' ); 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' ); define( 'TMDO_HIVEPRESS_FILE', TMDO_HIVEPRESS_PATH . '2meet-data-optimizer-hivepress-addon.php' );
+1 -1
View File
@@ -62,7 +62,7 @@ class HivePressAttributeBridgeTest extends TestCase {
$this->assertSame( 'listing', $record['model'] ); $this->assertSame( 'listing', $record['model'] );
$this->assertSame( 'beds', $record['slug'] ); $this->assertSame( 'beds', $record['slug'] );
$this->assertSame( 'hp_listing_beds', $record['meta_key'] ); $this->assertSame( 'hp_beds', $record['meta_key'] );
$this->assertSame( 'integer', $record['type'] ); $this->assertSame( 'integer', $record['type'] );
$this->assertTrue( $record['filterable'] ); $this->assertTrue( $record['filterable'] );
$this->assertFalse( $record['sortable'] ); $this->assertFalse( $record['sortable'] );
+87 -1
View File
@@ -37,9 +37,15 @@ class HivePressEntityGroupsTest extends TestCase {
'hp_user' => 'hp_vendor_core', 'hp_user' => 'hp_vendor_core',
); );
private function post_adapter(): TMDO_Entity_Adapter_Interface {
$adapter = $this->createMock( TMDO_Entity_Adapter_Interface::class );
$adapter->method( 'get_entity_id_column' )->willReturn( 'post_id' );
return $adapter;
}
protected function setUp(): void { protected function setUp(): void {
TMDO_Entity_Registry::init(); TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); TMDO_Entity_Registry::register_adapter( 'post', $this->post_adapter() );
TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
TMDO_HivePress_Entity_Groups::register_entity_fields(); TMDO_HivePress_Entity_Groups::register_entity_fields();
} }
@@ -58,6 +64,86 @@ class HivePressEntityGroupsTest extends TestCase {
} }
} }
/**
* HivePress filters and sorts listings by these; they are declared as
* group indexes (added in place by core) rather than `searchable`, which
* would change the pinned hash and rebuild the table.
*/
public function test_listing_columns_filtered_and_sorted_on_are_indexed(): void {
$this->assertSame(
array(
'hourly_rate' => array( 'hp_hourly_rate' ),
'rating_count' => array( 'hp_rating_count' ),
// Sorts: the value then the ID tie-break, read in order (2.4 PR-119).
'price_id' => array( 'hp_price', 'post_id' ),
'rating_id' => array( 'hp_rating', 'post_id' ),
'hourly_rate_id' => array( 'hp_hourly_rate', 'post_id' ),
),
TMDO_Entity_Registry::get_group_indexes( 'post', 'hp_listing_core' )
);
}
public function test_site_listing_attributes_are_opt_in(): void {
$this->assertSame( array(), TMDO_Entity_Registry::get_group_fields( 'post', 'hp_listing_attrs' ) );
}
/**
* A site flattens its own HivePress listing attributes (e.g. a "Warranty"
* number attribute → hp_warranty) in a group of their own: a new table,
* indexed at creation; hp_listing_core and its pinned hash stay as they are.
*/
public function test_site_listing_attributes_get_their_own_indexed_group(): void {
$fields = static fn() => array(
'hp_warranty' => 'integer',
'hp_price' => 'decimal', // Already in hp_listing_core.
'warranty' => 'integer', // Not a HivePress key.
'hp_color' => 'colour', // Unknown type.
'hp_features' => 'json', // No plain index on LONGTEXT.
);
add_filter( 'tmdo_hp_listing_attribute_fields', $fields );
$GLOBALS['_wp_did_action']['after_setup_theme'] = 1;
try {
TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'post', $this->post_adapter() );
TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
TMDO_HivePress_Entity_Groups::register_entity_fields();
} finally {
$GLOBALS['_wp_filter_callbacks']['tmdo_hp_listing_attribute_fields'] = array();
unset( $GLOBALS['_wp_did_action'] );
}
$this->assertSame( array( 'hp_warranty' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ) );
$this->assertTrue( (bool) TMDO_Entity_Registry::get_group_fields( 'post', 'hp_listing_attrs' )[0]['searchable'] );
$this->assertSame( array( 'hp_listing' ), TMDO_Entity_Registry::get_group_post_types( 'post', 'hp_listing_attrs' ) );
$this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_price' )['group'] );
$this->test_definitions_match_persisted_schema_hashes();
}
/**
* Registration runs twice (plugins_loaded, then init:1): the group is
* registered on the run after themes load, so a theme's filter counts and
* a filter that changed between runs cannot collide (review F8).
*/
public function test_site_listing_attributes_are_registered_once_themes_are_loaded(): void {
add_filter( 'tmdo_hp_listing_attribute_fields', static fn() => array( 'hp_warranty' => 'integer' ) );
try {
TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'post', $this->post_adapter() );
TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
TMDO_HivePress_Entity_Groups::register_entity_fields();
$this->assertSame( array(), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ), 'plugins_loaded run: not yet' );
$GLOBALS['_wp_did_action']['after_setup_theme'] = 1;
TMDO_HivePress_Entity_Groups::register_entity_fields();
} finally {
$GLOBALS['_wp_filter_callbacks']['tmdo_hp_listing_attribute_fields'] = array();
unset( $GLOBALS['_wp_did_action'] );
}
$this->assertSame( array( 'hp_warranty' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ) );
$this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
}
public function test_shared_keys_keep_their_current_owner(): void { public function test_shared_keys_keep_their_current_owner(): void {
foreach ( self::SHARED_KEY_OWNERS as $key => $group ) { foreach ( self::SHARED_KEY_OWNERS as $key => $group ) {
$this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key )['group'], $key ); $this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key )['group'], $key );