34 Commits

Author SHA1 Message Date
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
wpdev a01314cbb1 refactor(2meet-data-optimizer-hivepress-addon): leave HivePress queries to the post router
Tests / PHP Lint (pull_request) Successful in 1m5s
Tests / Unit Tests (pull_request) Successful in 1m9s
Tests / Integration Tests (pull_request) Failing after 1m46s
- Do not bind optimize_query / optimize_search on hivepress/v1/models/{listing,vendor}/{query,search} while core's TMDO_Post_Query_Router is active: core now routes HivePress searches (filters and _sort) to the entity flat tables, and two routers would claim the same clauses
- Version 0.2.0 (released with core 1.1.0); CHANGELOG
- Verify: composer test (144, +2 binding tests); integration 49; dev36 fe.sh 9 URLs ids == native (except known flat drift)
- PR: PR-063
2026-09-26 20:19:27 +08:00
wpdev 61762c49d0 Merge pull request 'hardening 2.0: 2meet-data-optimizer-hivepress-addon (staged rollout, dev22 first)' (#1) from hardening/2.0 into main
Tests / PHP Lint (push) Successful in 5s
Tests / Unit Tests (push) Successful in 1m6s
Tests / Integration Tests (push) Successful in 1m18s
2026-09-26 10:24:16 +08:00
wpdev 0254e40d83 refactor(2meet-data-optimizer-hivepress-addon): stop registering Hot zone fields
Tests / PHP Lint (pull_request) Successful in 50s
Tests / Unit Tests (pull_request) Successful in 58s
Tests / Integration Tests (pull_request) Successful in 57s
- ADR-003 step 4 (AddOn side): core, requests, bookings, memberships and
  marketplace adapters no longer register zone=hot fields or contribute
  hot_* FSM modules; their keys live in TMDO_HivePress_Entity_Groups.
  Cold/warm registrations are unchanged.
- Hot-table doctor probes go with them (trait defaults apply). The
  hot-clause query rewrite and cron optimizer are now inert and are left
  for the ADR step-5 removal together with core's hot path.
- New NoHotZoneRegistrationTest pins the invariant across all 13 adapters;
  per-adapter hot assertions removed.
- Verify: HP unit 142 / integration 49 green, phpcs clean (WordPress)
- PR: PR-037
2026-09-26 01:13:35 +08:00
wpdev 0e260ee3a9 feat(2meet-data-optimizer-hivepress-addon): register Hot-only HivePress fields as entity groups
- New scoped post groups for fields only the Hot zone covered (ADR-003):
  hp_draft_flags (hp_listing, hp_request: hp_drafted), hp_booking_core,
  hp_membership_plan_core, hp_membership_core. hp_drafted lives in one group
  spanning both post types, so it adds no shared key.
- New groups register before the existing ones: the legacy (unscoped)
  owner of hp_expired_time stays hp_request_core; existing hashes unchanged.
- Reconciler now also plans hp_membership rows of hp_expired_time out of
  hp_request_core. dev20-22 hold 0 native rows for these keys.
- Verify: HP unit 151 / integration 49 green, phpcs clean
- PR: PR-036
2026-09-26 01:09:34 +08:00
wpdev 69c8bf2a88 feat(2meet-data-optimizer-hivepress-addon): provide HivePress defaults to core via filters
- TMDO_HivePress_Entity_Groups hooks tmdo_rest_default_post_type so GET /wpdo/v1/listings keeps defaulting to hp_listing now that core defaults to 'post' (PR-020e)
- Test: TMDO_Post_Group_Reconciler::plan() over the AddOn's groups yields the 8 misrouted HivePress keys (coverage moved out of core in PR-020b)
- Verify: HP unit 148 / integration 49 OK, bootstrap smoke OK
- PR: PR-020f
2026-09-26 00:24:43 +08:00
wpdev dca134b5b6 refactor(2meet-data-optimizer-hivepress-addon): remove TMDO_HPCT_Import
- Delete the HPCT importer (no caller left after core dropped HPCT in PR-020c) and its bootstrap/alias/test-bootstrap entries
- Conflict guard notice, log line and HivePress admin tab no longer point to the never-existing `wp wpdo hivepress migrate-from-hpct`; they say to deactivate the legacy plugins (their own tables are not imported)
- Verify: ConflictGuardTest guard; HP unit 146 / integration 49 OK, bootstrap smoke OK
- PR: PR-020d
2026-09-26 00:16:00 +08:00
wpdev 3c57fb0ee6 fix(2meet-data-optimizer-hivepress-addon): declare post_type scopes on HivePress post groups
- hp_listing_core / hp_request_core / hp_vendor_core declare hp_listing / hp_request / hp_vendor, matching core (PR-033), so the AddOn keeps scoped routing once core stops registering them (PR-020b)
- Test: with tmdo_post_scoped_routing on, every key of each group resolves to its own post type's group; legacy owner and hash pins unchanged
- Verify: HP unit 145 / integration 49 OK
- PR: PR-033b
2026-09-25 23:57:52 +08:00
wpdev 7d0edb6228 test(2meet-data-optimizer-hivepress-addon): pin hp entity group definitions to persisted schema hashes
- HivePressEntityGroupsTest no longer loads core TMDO_Post_Fields / TMDO_Member_Fields, so it survives core dropping them (PR-020b)
- Pin the 4 groups to their wpdo_registry_meta hashes and the current owner of the 8 shared post keys (documented as existing behaviour, not intent)
- Verify: HP unit 144 OK; mutating one field type fails the hash test
- PR: PR-020a
2026-09-25 23:49:00 +08:00
wpdev 19add5fc16 refactor(2meet-data-optimizer-hivepress-addon): take ownership of hp_* entity groups
- Add TMDO_HivePress_Entity_Groups registering hp_listing_core / hp_request_core / hp_vendor_core / hp_user, copied verbatim from core (same order; schema hashes match the persisted wpdo_registry_meta rows)
- Drop the core adapter's conflicting hp_user definition (hp_verified / hp_avatar_id — keys HivePress never writes)
- Verify: HivePressEntityGroupsTest (identical to core, same shared-key resolution, no collisions); HP unit 144 / integration 49 OK; hashes = dev20 registry_meta
- PR: PR-019
2026-09-25 23:43:40 +08:00
wpdev ff6d6b10e4 refactor(2meet-data-optimizer-hivepress-addon): remove HPCT-era interceptors and query rewriters
- Delete 7 interceptors + 5 query rewriters that read/write hpct_* tables no site has (only an empty hpct_latepoint_idx exists)
- Drop their loading/registration in TMDO_HP_Bootstrap, back-compat aliases, integration bootstrap, and ListingMetaInterceptorTest
- Verify: unit 141 OK, integration 49 OK, bootstrap smoke OK; phpcs 34E/40W → 25E/28W
- PR: PR-014
2026-09-25 23:01:56 +08:00
wpdev 5777210184 refactor(2meet-data-optimizer-hivepress-addon): remove comment mirror writes to never-created tables
- Favorites/Messages adapters reduced to detection only; Requests keeps hot_hp_request fields, drops hp_offer mirror
- Delete TMDO_HivePress_Comment_Router (read path to wpdo_comment_hp_* tables that no installer creates)
- Verify: addon unit 141 OK, integration 52 OK; phpcs 41E/57W → 35E/48W
- PR: PR-013
2026-09-25 22:58:34 +08:00
wpdev f089b0934b fix(2meet-data-optimizer-hivepress-addon): map marketplace hot field to hp_price
- hivepress-marketplace stores its filterable, sortable listing price as
  hp_price; hp_purchase_price never existed (dev34: 588 hp_price rows)
- Drop hp_purchase_count: the addon keeps no per-listing sales counter
- Remove the stale docblock note about the legacy core registration path

Verify: vendor/bin/phpunit --filter test_on_register_fields_registers_hot_hp_price;
  addon unit 156 OK, integration 57 OK
PR: PR-011
2026-09-25 22:39:57 +08:00
52 changed files with 1232 additions and 3615 deletions
+2 -2
View File
@@ -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.1.0
* Version: 0.5.1
* 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.1.0' );
define( 'TMDO_HIVEPRESS_VERSION', '0.5.1' );
define( 'TMDO_HIVEPRESS_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_HIVEPRESS_URL', plugin_dir_url( __FILE__ ) );
define( 'TMDO_HIVEPRESS_FILE', __FILE__ );
+130
View File
@@ -9,6 +9,136 @@ Versioning follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
---
## [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
Released with 2meet-data-optimizer 1.1.0 (hardening 2.1, search-index mode).
### Changed
- HivePress model query / search hooks (`optimize_query`, `optimize_search`)
are no longer bound while core's post router is active
(`TMDO_Post_Query_Router::is_router_active()`): core routes HivePress
searches, including `_sort`, to the entity flat tables, and two routers
would claim the same clauses.
### Added
- `tests/integration/`:bootstrap(複用核心的 integration bootstrap,連真實
+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
- WordPress ≥ 6.0
+3 -3
View File
@@ -85,9 +85,9 @@ if ( ! class_exists( 'TMDO_Admin_HivePress' ) ) {
<tr>
<td><code><?php echo esc_html( $slug ); ?></code></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 $is_bound ? '✅' : '—'; ?></td>
<td><?php echo esc_html( $is_bound ? '✅' : '—' ); ?></td>
<td><?php echo null === $score ? '—' : esc_html( (string) $score ); ?></td>
</tr>
<?php endforeach; ?>
@@ -138,7 +138,7 @@ wp wpdo hivepress benchmark</pre>
. esc_html__( '偵測到衝突外掛:', 'tmdo-hivepress' ) . '</strong> '
. esc_html( implode( ', ', $conflicts ) )
. '</p><p>'
. esc_html__( 'WPDO HivePress integration 已自動退讓。請執行 `wp wpdo hivepress migrate-from-hpct` 後停用舊外掛。', 'tmdo-hivepress' )
. esc_html__( 'WPDO HivePress integration 已自動退讓。停用舊外掛後即由 WPDO 接手(舊外掛自有資料表的資料不會匯入)。', 'tmdo-hivepress' )
. '</p></div>';
}
+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.
+1 -14
View File
@@ -20,8 +20,6 @@ add_action(
$aliases = array(
'TMDO_Admin_HivePress' => 'WPDO_Admin_HivePress',
'TMDO_CLI_HivePress' => 'WPDO_CLI_HivePress',
'TMDO_Favorites_Interceptor' => 'WPDO_Favorites_Interceptor',
'TMDO_HPCT_Import' => 'WPDO_HPCT_Import',
'TMDO_HP_Bootstrap' => 'WPDO_HP_Bootstrap',
'TMDO_HivePress' => 'WPDO_HivePress',
'TMDO_HivePress_Attribute_Bridge' => 'WPDO_HivePress_Attribute_Bridge',
@@ -29,9 +27,9 @@ add_action(
'TMDO_HivePress_Blocks_Adapter' => 'WPDO_HivePress_Blocks_Adapter',
'TMDO_HivePress_Bookings_Adapter' => 'WPDO_HivePress_Bookings_Adapter',
'TMDO_HivePress_Bootstrap' => 'WPDO_HivePress_Bootstrap',
'TMDO_HivePress_Comment_Router' => 'WPDO_HivePress_Comment_Router',
'TMDO_HivePress_Conflict_Guard' => 'WPDO_HivePress_Conflict_Guard',
'TMDO_HivePress_Core_Adapter' => 'WPDO_HivePress_Core_Adapter',
'TMDO_HivePress_Entity_Groups' => 'WPDO_HivePress_Entity_Groups',
'TMDO_HivePress_Cron_Optimizer' => 'WPDO_HivePress_Cron_Optimizer',
'TMDO_HivePress_Detector' => 'WPDO_HivePress_Detector',
'TMDO_HivePress_Favorites_Adapter' => 'WPDO_HivePress_Favorites_Adapter',
@@ -48,18 +46,7 @@ add_action(
'TMDO_HivePress_Tags_Adapter' => 'WPDO_HivePress_Tags_Adapter',
'TMDO_Hivepress_Term_Comment_Fields' => 'WPDO_Hivepress_Term_Comment_Fields',
'TMDO_Hivepress_Transient_Filter' => 'WPDO_Hivepress_Transient_Filter',
'TMDO_Listing_Meta_Interceptor' => 'WPDO_Listing_Meta_Interceptor',
'TMDO_Listing_Meta_Query' => 'WPDO_Listing_Meta_Query',
'TMDO_Listing_Stats' => 'WPDO_Listing_Stats',
'TMDO_Memberships_Interceptor' => 'WPDO_Memberships_Interceptor',
'TMDO_Memberships_Query' => 'WPDO_Memberships_Query',
'TMDO_Messages_Interceptor' => 'WPDO_Messages_Interceptor',
'TMDO_Messages_Query' => 'WPDO_Messages_Query',
'TMDO_Requests_Interceptor' => 'WPDO_Requests_Interceptor',
'TMDO_Requests_Query' => 'WPDO_Requests_Query',
'TMDO_Reviews_Interceptor' => 'WPDO_Reviews_Interceptor',
'TMDO_Reviews_Query' => 'WPDO_Reviews_Query',
'TMDO_Statistics_Interceptor' => 'WPDO_Statistics_Interceptor',
);
foreach ( $aliases as $tmdo_class => $wpdo_alias ) {
if ( class_exists( $tmdo_class, false ) && ! class_exists( $wpdo_alias, false ) ) {
+5 -61
View File
@@ -2,8 +2,7 @@
/**
* Bootstrap for 2meet Data Optimizer HivePress AddOn.
*
* 載入 13 adapter + 7 interceptor + 5 query interceptor + 補助組件 +
* admin tab + CLI namespace。
* 載入 13 adapter + 補助組件 + admin tab + CLI namespace。
*
* 注意:HP 的 V3.0.0 family bootstrap 內部也有一個 class TMDO_HivePress_Bootstrap
* (在 includes/hivepress/class-tmdo-hivepress-bootstrap.php)。為避免衝突,本
@@ -44,7 +43,6 @@ final class TMDO_HP_Bootstrap {
require_once $base . 'includes/hivepress/class-tmdo-hivepress-detector.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-bootstrap.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-comment-router.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php';
// 13 adapters
@@ -65,40 +63,11 @@ final class TMDO_HP_Bootstrap {
require_once $base . 'includes/hivepress/class-tmdo-hivepress-benchmark.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-rest.php';
// 7 HPCT interceptors.
// reviews / messages / memberships / requests extend the core's
// TMDO_Standard_Post_Interceptor — requiring them against a core older than
// v0.2.0 would fatal at parse time, so gate those four on the base class.
$has_standard_base = class_exists( 'TMDO_Standard_Post_Interceptor' );
foreach ( array(
'reviews', 'messages', 'favorites', 'memberships',
'statistics', 'requests', 'listing-meta',
) as $intslug ) {
$needs_base = in_array( $intslug, array( 'reviews', 'messages', 'memberships', 'requests' ), true );
if ( $needs_base && ! $has_standard_base ) {
continue;
}
$file = $base . "includes/interceptors/class-tmdo-{$intslug}-interceptor.php";
if ( file_exists( $file ) ) {
require_once $file;
}
}
// 5 query interceptors
foreach ( array(
'reviews', 'messages', 'memberships', 'requests', 'listing-meta',
) as $qslug ) {
$file = $base . "includes/query/class-tmdo-{$qslug}-query.php";
if ( file_exists( $file ) ) {
require_once $file;
}
}
// Misc HP integration files
require_once $base . 'includes/class-tmdo-hivepress.php';
require_once $base . 'includes/class-tmdo-hivepress-transient-filter.php';
require_once $base . 'includes/class-tmdo-hpct-import.php';
require_once $base . 'includes/class-tmdo-hivepress-term-comment-fields.php';
require_once $base . 'includes/class-tmdo-hivepress-entity-groups.php';
require_once $base . 'includes/class-tmdo-listing-stats.php';
}
@@ -111,34 +80,6 @@ final class TMDO_HP_Bootstrap {
TMDO_HivePress_Bootstrap::boot();
}
// 7 HPCT interceptors
foreach ( array(
'TMDO_Reviews_Interceptor',
'TMDO_Messages_Interceptor',
'TMDO_Favorites_Interceptor',
'TMDO_Memberships_Interceptor',
'TMDO_Statistics_Interceptor',
'TMDO_Requests_Interceptor',
'TMDO_Listing_Meta_Interceptor',
) as $cls ) {
if ( class_exists( $cls ) ) {
( new $cls() )->register_hooks();
}
}
// 5 query interceptors
foreach ( array(
'TMDO_Reviews_Query',
'TMDO_Messages_Query',
'TMDO_Memberships_Query',
'TMDO_Requests_Query',
'TMDO_Listing_Meta_Query',
) as $cls ) {
if ( class_exists( $cls ) ) {
( new $cls() )->register_hooks();
}
}
// HP transient filter (init:5)
if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 );
@@ -156,6 +97,9 @@ final class TMDO_HP_Bootstrap {
TMDO_Hivepress_Term_Comment_Fields::register();
}
// Post + user entity groups (hp_listing_core / hp_request_core / hp_vendor_core / hp_user).
TMDO_HivePress_Entity_Groups::register();
// Admin tab
if ( is_admin() ) {
$admin_file = TMDO_HIVEPRESS_PATH . 'admin/class-tmdo-admin-hivepress.php';
@@ -0,0 +1,435 @@
<?php
/**
* HivePress entity groups owned by the HivePress AddOn.
*
* Groups → flat tables:
* post:hp_listing_core → wp_wpdo_post_hp_listing_core (hp_listing)
* post:hp_request_core → wp_wpdo_post_hp_request_core (hp_request)
* post:hp_vendor_core → wp_wpdo_post_hp_vendor_core (hp_vendor)
* user:hp_user → wp_wpdo_user_hp_user
*
* Fields only the Hot zone used to cover (ADR-003), as their own groups:
* post:hp_draft_flags (hp_listing, hp_request)
* post:hp_booking_core (hp_booking)
* post:hp_membership_plan_core (hp_membership_plan)
* post:hp_membership_core (hp_membership)
* They register FIRST so the legacy last-registered owner of hp_expired_time
* stays hp_request_core.
*
* The term/comment groups (hp_taxonomy, hp_review) live in
* TMDO_Hivepress_Term_Comment_Fields.
*
* ⚠️ These definitions must stay byte-identical to the ones live sites
* persisted in wpdo_registry_meta (originally registered by core's
* TMDO_Post_Fields / TMDO_Member_Fields). Any drift changes the schema hash
* and triggers ALTER TABLE on flat tables that may hold aeav_only data.
* The three post groups share keys (hp_status, hp_user, …): each declares
* its post type so a post's key resolves to its own type's group, while
* lookups without a post type keep last-registered-wins — keep their order.
*
* @package TMDO_HIVEPRESS
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers the HivePress post + user entity groups.
*/
final class TMDO_HivePress_Entity_Groups {
/**
* Hook into WPDO entity field registration and the REST default post type.
*
* @return void
*/
public static function register(): void {
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
add_filter( 'tmdo_rest_default_post_type', array( __CLASS__, 'rest_default_post_type' ) );
}
/**
* GET /wpdo/v1/listings serves HivePress listings when no post type is given.
*
* @return string
*/
public static function rest_default_post_type(): string {
return 'hp_listing';
}
/**
* Register the groups (order matters, see file header).
*
* @return void
*/
public static function register_entity_fields(): void {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
self::register_hp_draft_flags_group();
self::register_hp_booking_core_group();
self::register_hp_membership_plan_core_group();
self::register_hp_membership_core_group();
self::register_hp_listing_core_group();
self::register_hp_request_core_group();
self::register_hp_vendor_core_group();
self::register_hp_user_group();
self::register_hp_listing_attributes_group();
}
// ── Group definitions ────────────────────────────────────────────────────
/**
* Draft flag shared by listings and requests (user-dashboard filtering).
*
* @return void
*/
private static function register_hp_draft_flags_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_draft_flags',
array(
array(
'key' => 'hp_drafted',
'type' => 'integer',
'searchable' => true,
'label' => 'Drafted flag (0/1)',
),
),
array( 'post_types' => array( 'hp_listing', 'hp_request' ) )
);
}
/**
* HivePress Bookings (post_type=hp_booking): slot-availability lookups.
*
* @return void
*/
private static function register_hp_booking_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_booking_core',
array(
array(
'key' => 'hp_start_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Booking start unix ts',
),
array(
'key' => 'hp_end_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Booking end unix ts',
),
array(
'key' => 'hp_quantity',
'type' => 'integer',
'label' => 'Places booked',
),
),
array( 'post_types' => array( 'hp_booking' ) )
);
}
/**
* HivePress Memberships plan (post_type=hp_membership_plan).
*
* @return void
*/
private static function register_hp_membership_plan_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_membership_plan_core',
array(
array(
'key' => 'hp_expire_period',
'type' => 'integer',
'label' => 'Plan expiry period (days)',
),
array(
'key' => 'hp_primary',
'type' => 'integer',
'searchable' => true,
'label' => 'Primary plan flag (0/1)',
),
),
array( 'post_types' => array( 'hp_membership_plan' ) )
);
}
/**
* HivePress Memberships subscription (post_type=hp_membership).
*
* @return void
*/
private static function register_hp_membership_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_membership_core',
array(
array(
'key' => 'hp_expired_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Expiry unix ts',
),
),
array( 'post_types' => array( 'hp_membership' ) )
);
}
/**
* HivePress listing core meta keys (post_type=hp_listing).
* Aligns with the existing wpdo_hot_hp_listing flat table; v2.9.5 will
* copy-then-cutover the 230 dev10 rows to this group's table.
*
* @return void
*/
private static function register_hp_listing_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_listing_core',
array(
array(
'key' => 'hp_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Listing price',
),
array(
'key' => 'hp_status',
'type' => 'enum',
'searchable' => true,
'options' => array( 'publish', 'draft', 'pending', 'expired', 'private' ),
'label' => 'Listing status',
),
array(
'key' => 'hp_featured',
'type' => 'integer',
'searchable' => true,
'label' => 'Featured flag (0/1)',
),
array(
'key' => 'hp_verified',
'type' => 'integer',
'searchable' => true,
'label' => 'Verified flag (0/1)',
),
array(
'key' => 'hp_vendor',
'type' => 'integer',
'searchable' => true,
'label' => 'Vendor user ID',
),
array(
'key' => 'hp_expired_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Expiry unix ts',
),
array(
'key' => 'hp_featured_time',
'type' => 'integer',
'label' => 'Featured-until unix ts',
),
array(
'key' => 'hp_view_count',
'type' => 'integer',
'searchable' => true,
'label' => 'View counter',
),
array(
'key' => 'hp_rating',
'type' => 'decimal',
'searchable' => true,
'label' => 'Average rating',
),
array(
'key' => 'hp_rating_count',
'type' => 'integer',
'label' => 'Rating count',
),
array(
'key' => 'hp_hourly_rate',
'type' => 'decimal',
'label' => 'Hourly rate',
),
),
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).
*
* @return void
*/
private static function register_hp_request_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_request_core',
array(
array(
'key' => 'hp_status',
'type' => 'enum',
'searchable' => true,
'options' => array( 'publish', 'draft', 'pending', 'expired' ),
'label' => 'Request status',
),
array(
'key' => 'hp_user',
'type' => 'integer',
'searchable' => true,
'label' => 'Request author user ID',
),
array(
'key' => 'hp_expired_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Expiry unix ts',
),
array(
'key' => 'hp_budget',
'type' => 'decimal',
'searchable' => true,
'label' => 'Budget amount',
),
array(
'key' => 'hp_view_count',
'type' => 'integer',
'label' => 'View counter',
),
),
array( 'post_types' => array( 'hp_request' ) )
);
}
/**
* HivePress vendor core meta keys (post_type=hp_vendor).
*
* @return void
*/
private static function register_hp_vendor_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_vendor_core',
array(
array(
'key' => 'hp_user',
'type' => 'integer',
'searchable' => true,
'label' => 'Vendor user ID (linked WP user)',
),
array(
'key' => 'hp_verified',
'type' => 'integer',
'searchable' => true,
'label' => 'Verified flag (0/1)',
),
array(
'key' => 'hp_hourly_rate',
'type' => 'decimal',
'searchable' => true,
'label' => 'Hourly rate',
),
array(
'key' => 'hp_rating_count',
'type' => 'integer',
'label' => 'Rating count',
),
array(
'key' => 'hp_rating',
'type' => 'decimal',
'searchable' => true,
'label' => 'Average rating',
),
),
array( 'post_types' => array( 'hp_vendor' ) )
);
}
/**
* HivePress per-user fields (favorites + avatar attachment).
*
* Hp_favorited_listings is a serialized array of post IDs in legacy storage;
* the migration engine safe_unserialize()s it (allowed_classes=false to block
* PHP-object injection) then the json type encodes back to a JSON array column.
*
* @return void
*/
private static function register_hp_user_group(): void {
TMDO_Entity_Registry::register_group(
'user',
'hp_user',
array(
array(
'key' => 'hp_favorited_listings',
'type' => 'json',
'label' => 'HivePress favorited listing IDs (array)',
),
array(
'key' => 'hp_image',
'type' => 'text',
'label' => 'HivePress avatar attachment ID',
),
)
);
}
}
@@ -77,6 +77,12 @@ 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';
/** 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
* multiple times.
@@ -164,7 +170,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 +254,27 @@ 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). 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 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 );
$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 );
}
-248
View File
@@ -1,248 +0,0 @@
<?php
/**
* HPCT Import integration for WP Data Optimizer.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* HPCT Import — reads hp-custom-tables' feature flags and migration records,
* maps them to WPDO's 7-state lifecycle, and copies progress data.
*
* After import, WPDO takes ownership of all 9 HPCT modules and their
* corresponding hpct_* tables. The user is advised to deactivate hp-custom-tables.
*
* Usage:
* - Admin UI: Tools > WP Data Optimizer > HPCT Import tab
* - CLI: wp wpdo import-hpct
*/
class TMDO_HPCT_Import {
/** Option key that marks import as completed. */
public const IMPORTED_OPTION = 'wpdo_hpct_imported';
/**
* Check if HPCT import has already been completed.
*/
public static function is_imported(): bool {
return (bool) get_option( self::IMPORTED_OPTION, false );
}
/**
* Check if hp-custom-tables is active and has data to import.
*/
public static function can_import(): bool {
if ( self::is_imported() ) {
return false;
}
return TMDO_Compatibility::is_hpct_active();
}
/**
* Preview: return what will be imported without actually doing it.
*
* @return array{modules: array, migration_records: int, has_data: bool}
*/
public static function preview(): array {
$hpct_features = get_option( 'hpct_features', array() );
if ( ! is_array( $hpct_features ) ) {
$hpct_features = array();
}
$modules = array();
foreach ( TMDO_Feature_Flags::HPCT_MODULES as $module ) {
$hpct_status = $hpct_features[ $module ] ?? 'disabled';
$wpdo_state = TMDO_Feature_Flags::map_hpct_status( $hpct_status );
$modules[] = array(
'module' => $module,
'hpct_status' => $hpct_status,
'wpdo_state' => $wpdo_state,
);
}
// Count HPCT migration records.
global $wpdb;
$hpct_migrations_table = $wpdb->prefix . 'hpct_migrations';
$migration_count = 0;
// Check if hpct_migrations table exists.
if ( TMDO_IS_SQLITE ) {
$table_exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = %s",
$hpct_migrations_table
)
);
} else {
$table_exists = $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = %s',
$hpct_migrations_table
)
);
}
if ( $table_exists ) {
$migration_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hpct_migrations_table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix . 'hpct_migrations'.
}
return array(
'modules' => $modules,
'migration_records' => $migration_count,
'has_data' => ! empty( $hpct_features ),
);
}
/**
* Execute the import.
*
* @return array{success: bool, message: string, imported: array}
*/
public static function run(): array {
if ( self::is_imported() ) {
return array(
'success' => false,
'message' => 'HPCT import has already been completed.',
'imported' => array(),
);
}
// Step 1: Read HPCT feature flags.
$hpct_features = get_option( 'hpct_features', array() );
if ( ! is_array( $hpct_features ) ) {
$hpct_features = array();
}
$imported = array();
// Step 2: Map each HPCT module state to WPDO state.
foreach ( TMDO_Feature_Flags::HPCT_MODULES as $module ) {
$hpct_status = $hpct_features[ $module ] ?? 'disabled';
$wpdo_state = TMDO_Feature_Flags::map_hpct_status( $hpct_status );
TMDO_Feature_Flags::set( $module, $wpdo_state );
$imported[] = array(
'module' => $module,
'hpct_status' => $hpct_status,
'wpdo_state' => $wpdo_state,
);
}
// Step 3: Copy HPCT migration records to WPDO.
self::copy_migration_records();
// Step 4: Mark import as completed.
update_option( self::IMPORTED_OPTION, true );
return array(
'success' => true,
'message' => sprintf(
'Successfully imported %d HPCT modules. You can now deactivate hp-custom-tables.',
count( $imported )
),
'imported' => $imported,
);
}
/**
* Copy HPCT migration progress records to wpdo_migrations.
*/
private static function copy_migration_records(): void {
global $wpdb;
$hpct_table = $wpdb->prefix . 'hpct_migrations';
$wpdo_table = TMDO_DB::table( 'wpdo_migrations' );
$now = TMDO_DB::now();
// Check if hpct_migrations table exists.
$exists = false;
if ( TMDO_IS_SQLITE ) {
$exists = (bool) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = %s",
$hpct_table
)
);
} else {
$exists = (bool) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = %s',
$hpct_table
)
);
}
if ( ! $exists ) {
return;
}
$hpct_records = $wpdb->get_results( "SELECT * FROM `{$hpct_table}`", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix . 'hpct_migrations'.
if ( ! $hpct_records ) {
return;
}
foreach ( $hpct_records as $record ) {
$module = $record['module'] ?? '';
if ( ! $module ) {
continue;
}
// Map HPCT status to WPDO state.
$hpct_status = $record['status'] ?? 'pending';
$state = self::map_migration_status( $hpct_status );
// Check if WPDO already has a record for this module.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_DB::table().
$existing = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM `{$wpdo_table}` WHERE module = %s",
$module
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$data = array(
'module' => $module,
'zone' => '',
'state' => $state,
'total_rows' => (int) ( $record['total_rows'] ?? 0 ),
'processed_rows' => (int) ( $record['processed_rows'] ?? 0 ),
'last_offset' => (int) ( $record['last_offset'] ?? 0 ),
'error_count' => (int) ( $record['error_count'] ?? 0 ),
'started_at' => $record['started_at'] ?? null,
'completed_at' => $record['completed_at'] ?? null,
'updated_at' => $now,
);
if ( $existing ) {
$wpdb->update( $wpdo_table, $data, array( 'id' => (int) $existing ) );
} else {
$data['created_at'] = $now;
$wpdb->insert( $wpdo_table, $data );
}
}
}
/**
* Map HPCT migration record status to WPDO migration state.
*
* @param string $hpct_status HPCT migration status string.
* @return string Corresponding WPDO migration state.
*/
private static function map_migration_status( string $hpct_status ): string {
return match ( $hpct_status ) {
'pending' => 'idle',
'running' => 'backfill',
'completed' => 'verify',
'failed' => 'idle',
default => 'idle',
};
}
}
@@ -8,18 +8,13 @@
* Booking model (post_type=hp_booking) — fields are best-effort from the
* addon's documented field aliases:
*
* start_time / end_time → unix timestamp range queries (hot)
* status → enum filter (hot)
* quantity → places booked, hp_quantity (hot for slot-availability;
* only written when "Allow multiple places" is on)
* start_time / end_time → unix timestamp range queries
* quantity → places booked, hp_quantity (only written when
* "Allow multiple places" is on)
* listing → post_parent (already a posts column)
*
* Hot-zone target: `wp_wpdo_hot_hp_booking` with covering indexes on
* `(listing_id, start_time)` for slot-availability queries and
* `(status, start_time)` for vendor dashboard listings.
*
* The schema MAY drift from real Booking model when the addon is finally
* installed; reconcile with `wp wpdo doctor` then.
* These keys are flattened by the `hp_booking_core` entity group
* (TMDO_HivePress_Entity_Groups); the adapter registers no zone fields.
*
* @package WP_Data_Optimizer
* @since 3.0.0
@@ -65,73 +60,6 @@ if ( ! class_exists( 'TMDO_HivePress_Bookings_Adapter' ) ) {
return '1.0.0';
}
/**
* Register hot fields for hp_booking post type.
*
* @param TMDO_Schema_Registry $registry Schema registry singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
$registry->register_many(
$this->plugin_slug(),
array(
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_start_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_start_time',
'indexed' => true,
),
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_end_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_end_time',
'indexed' => true,
),
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_quantity',
'zone' => 'hot',
'data_type' => 'int(11) NOT NULL DEFAULT 0',
'column' => 'hp_quantity',
'indexed' => false,
),
)
);
}
/**
* Doctor probe — verify hot table when addon is active.
*
* @return array{ok:bool, message:string}
*/
public function doctor_check(): array {
global $wpdb;
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
return array(
'ok' => false,
'message' => 'wpdb global not available',
);
}
$table = $wpdb->prefix . 'wpdo_hot_hp_booking';
try {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
return array(
'ok' => $exists,
'message' => $exists
? sprintf( 'hivepress-bookings: %s present', $table )
: sprintf( 'hivepress-bookings: %s missing (addon installed but not migrated)', $table ),
);
} catch ( \Throwable $e ) {
return array(
'ok' => false,
'message' => sprintf( 'hivepress-bookings doctor failed: %s', $e->getMessage() ),
);
}
}
/**
* 8-D self-score.
*
@@ -146,15 +74,6 @@ if ( ! class_exists( 'TMDO_HivePress_Bookings_Adapter' ) ) {
)
);
}
/**
* FSM modules this adapter contributes.
*
* @return array<int,string>
*/
public function migrations(): array {
return array( 'hot_hp_booking' );
}
}
} // end if ( ! class_exists )
@@ -2,15 +2,15 @@
/**
* Core HivePress adapter — `hivepress` (the parent plugin).
*
* Owns three primary HivePress models:
* Covers the primary HivePress models:
*
* - `Listing` (post_type=hp_listing) — Hot zone columns for search/filter
* (drafted, featured, verified, expired_time, featured_time).
* - `Vendor` (post_type=hp_vendor) — Hot column `verified`; cold blob
* for image / description (display-only).
* - `User` (entity=user) — Entity group `hp_user_core` for
* `hp_verified` and the WP-stock `first_name` / `last_name` /
* `description`.
* - `Listing` (post_type=hp_listing) — cold blob for the gallery.
* - `Vendor` (post_type=hp_vendor) — cold blob for the image.
* - `User` (entity=user) — no zone fields; its `hp_user`
* entity group is registered by TMDO_HivePress_Entity_Groups.
*
* Search/filter keys are flattened by TMDO_HivePress_Entity_Groups
* (ADR-003: no Hot zone fields).
*
* This adapter SUPERSEDES the legacy `class-tmdo-hivepress.php` (242 lines).
* Behavioural parity:
@@ -24,14 +24,9 @@
* adapter only registers `hivepress` core fields.
* - Vendor cold fields (`hp_image`, `hp_images`) — preserved.
*
* NEW additions vs legacy:
*
* - `hp_drafted` (Listing) — Hot tinyint(1) for user-dashboard filtering.
* - `hp_expired_time` / `hp_featured_time` (Listing) — Hot bigint indexed
* for cron expiry scan acceleration (replaces postmeta full scan).
* - `hp_user_core` entity group — first_name / last_name / hp_verified
* promoted from usermeta to wp_wpdo_user_hp_user (already created by
* TMDO_Member_Fields v2.7.0).
* (This adapter used to register a second `hp_user` definition —
* hp_verified / hp_avatar_id, keys HivePress never writes — which the
* registry silently dropped; TMDO_HivePress_Entity_Groups owns it now.)
*
* @package WP_Data_Optimizer
* @since 3.0.0
@@ -115,37 +110,10 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
* @param TMDO_Schema_Registry $registry Schema registry singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
// ── Listing hot fields (search/filter accelerators) ──
// ── Cold blobs (display-only) ──
$registry->register_many(
$this->plugin_slug(),
array(
// Drafted — used in user dashboard "my drafts" filter.
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_drafted',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_drafted',
'indexed' => true,
),
// Expired time — replaces `meta_query` clause in hourly cron.
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_expired_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_expired_time',
'indexed' => true,
),
// Featured time — same expiry pattern as above.
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_featured_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_featured_time',
'indexed' => true,
),
// Vendor cold blob — image attachment id (display-only).
array(
'post_type' => 'hp_vendor',
@@ -166,53 +134,18 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
);
}
/**
* Register entity field group for HP User model.
*
* The `hp_user` group is already created by `TMDO_Member_Fields`
* (v2.7.0). We add adapter ownership here for cross-cutting tooling
* (`wp wpdo hivepress doctor` reports owner; admin UI shows source).
*
* The `register_entity_fields()` helper from `TMDO_Anti_EAV_Aware`
* delegates to `TMDO_Entity_Registry::register_group()` which has
* internal dedup — so re-registering with same group name is a no-op.
*/
public function on_register_entity_fields(): void {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
// Mirror the (canonical) group definition from TMDO_Member_Fields::HP_USER_FIELDS.
// Source of truth lives in TMDO_Member_Fields; we register defensively for the
// case where Member_Fields didn't load (custom builds without that integration).
$this->register_entity_fields(
'user',
'hp_user',
array(
array(
'key' => 'hp_verified',
'type' => 'boolean',
'default' => false,
'searchable' => true,
'label' => 'HivePress KYC verified',
),
array(
'key' => 'hp_avatar_id',
'type' => 'integer',
'default' => 0,
'searchable' => false,
'label' => 'HivePress avatar attachment id',
),
)
);
}
// ── Query optimization (preserved from legacy class-tmdo-hivepress.php) ──
/**
* Bind HivePress search query hooks at priority 20 (after HP setup).
*
* Not while core's post router serves HivePress searches from the
* entity flat tables: two routers would claim the same clauses.
*/
public function on_register_query_hooks(): void {
if ( class_exists( 'TMDO_Post_Query_Router' ) && TMDO_Post_Query_Router::is_router_active() ) {
return;
}
foreach ( self::HOT_MODELS as $model => $post_type ) {
unset( $post_type ); // Bound below by string-interpolated $model only.
add_action(
@@ -318,57 +251,6 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
// ── Doctor / score / migrations ────────────────────────────────────
/**
* Health probe: verify hot tables exist + sample row count.
*
* @return array{ok:bool, message:string, details?:array<string,mixed>}
*/
public function doctor_check(): array {
global $wpdb;
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
return array(
'ok' => false,
'message' => 'wpdb global not available',
);
}
$details = array();
$ok = true;
foreach ( self::HOT_MODELS as $post_type ) {
$table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type );
try {
$exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
$details[ $post_type ] = array(
'table' => $table,
'exists' => $exists,
);
if ( $exists ) {
$details[ $post_type ]['rows'] = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$table}`" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Sanitized via sanitize_key + wpdb prefix.
);
} else {
$ok = false;
}
} catch ( \Throwable $e ) {
$details[ $post_type ] = array(
'table' => $table,
'error' => $e->getMessage(),
);
$ok = false;
}
}
return array(
'ok' => $ok,
'message' => $ok
? 'hivepress core: hot tables OK'
: 'hivepress core: one or more hot tables missing or unreadable',
'details' => $details,
);
}
/**
* 8-dimension self-score for the core adapter.
*
@@ -390,18 +272,6 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
)
);
}
/**
* FSM modules this adapter contributes.
*
* @return array<int,string>
*/
public function migrations(): array {
return array(
'hot_hp_listing',
'hot_hp_vendor',
);
}
}
} // end if ( ! class_exists )
@@ -8,27 +8,9 @@
* - listing → comment_post_ID
* - added_at → comment_date
*
* Problem: `wp_comments` has no UNIQUE constraint on (user_id, comment_post_ID, comment_type).
* A double-tap on the favourite button can race and create two duplicate
* favourite rows. HivePress relies on application-level dedup (SELECT before
* INSERT) which is not race-safe.
*
* Fix: shadow table `wp_wpdo_comment_hp_favorite` with UNIQUE(user_id, listing_id).
* Adapter mirrors every favorite write into the shadow table; the comment
* router rewrites "is X favorited by user Y" reads to hit the shadow table
* (1 indexed lookup vs WP_Comment_Query meta scan).
*
* Schema (declared via expected_columns; created by TMDO_Schema_Manager
* when adapter mode flips to dual_write):
*
* CREATE TABLE wp_wpdo_comment_hp_favorite (
* comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
* user_id BIGINT UNSIGNED NOT NULL,
* listing_id BIGINT UNSIGNED NOT NULL,
* created_at DATETIME NOT NULL,
* UNIQUE KEY unique_user_listing (user_id, listing_id),
* KEY idx_listing (listing_id)
* );
* Nothing is stored in comment meta, so there is no EAV surface to flatten:
* the adapter only reports detection and doctor status. (It used to mirror
* favorites into `wpdo_comment_hp_favorite`, a table nothing ever created.)
*
* @package WP_Data_Optimizer
* @since 3.0.0
@@ -41,18 +23,15 @@ if ( ! defined( 'ABSPATH' ) ) {
if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) {
/**
* Favorites addon adapter.
* Favorites addon adapter (detection and doctor only).
*/
final class TMDO_HivePress_Favorites_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/** Comment_type owned by this adapter. */
/** Comment_type of the Favorite model. */
public const COMMENT_TYPE = 'hp_favorite';
/** Shadow table name (without wpdb prefix). */
public const TABLE = 'wpdo_comment_hp_favorite';
/**
* Constructor — bind register hooks via inherited trait.
*/
@@ -79,156 +58,6 @@ if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) {
public function minimum_addon_version(): string {
return '1.2.0';
}
/**
* Declare the shadow table to the Custom_Table_Registry.
*
* @param TMDO_Custom_Table_Registry $registry Registry singleton.
*/
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void {
$this->register_custom_table(
$registry,
array(
'table_name' => self::TABLE,
'primary_key' => 'comment_id',
'post_type_link' => null,
'expected_columns' => array(
'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY',
'user_id' => 'BIGINT UNSIGNED NOT NULL',
'listing_id' => 'BIGINT UNSIGNED NOT NULL',
'created_at' => 'DATETIME NOT NULL',
),
'indexes' => array(
'unique_user_listing' => 'UNIQUE (user_id, listing_id)',
'idx_listing' => '(listing_id)',
),
'doctor_callback' => array( $this, 'doctor_check' ),
)
);
}
/**
* Sync wp_comments → shadow table on insert.
*
* Bound during `on_register_event_hooks()`. The comment-router uses
* the shadow table for "is favorited" lookups; sync keeps it consistent.
*/
public function on_register_event_hooks(): void {
add_action( 'wp_insert_comment', array( $this, 'mirror_insert' ), 10, 2 );
add_action( 'deleted_comment', array( $this, 'mirror_delete' ), 10, 2 );
}
/**
* Mirror a newly-inserted hp_favorite into the shadow table.
*
* @param int $comment_id Comment ID.
* @param object|\WP_Comment $comment Comment object.
*/
public function mirror_insert( int $comment_id, $comment ): void {
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
return;
}
global $wpdb;
$user_id = (int) ( $comment->user_id ?? 0 );
$listing_id = (int) ( $comment->comment_post_ID ?? 0 );
if ( $user_id <= 0 || $listing_id <= 0 ) {
return;
}
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
'INSERT IGNORE INTO `' . $wpdb->prefix . self::TABLE . '` (comment_id, user_id, listing_id, created_at) VALUES (%d, %d, %d, %s)',
$comment_id,
$user_id,
$listing_id,
$comment->comment_date ?? current_time( 'mysql' )
)
);
}
/**
* Remove the shadow row when a hp_favorite comment is deleted.
*
* @param int $comment_id Comment ID.
* @param object|\WP_Comment $comment Comment object.
*/
public function mirror_delete( int $comment_id, $comment ): void {
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
return;
}
global $wpdb;
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . self::TABLE,
array( 'comment_id' => $comment_id ),
array( '%d' )
);
}
/**
* Doctor probe — verify shadow table exists.
*
* @return array{ok:bool, message:string, details?:array<string,mixed>}
*/
public function doctor_check(): array {
global $wpdb;
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
return array(
'ok' => false,
'message' => 'wpdb global not available',
);
}
$table = $wpdb->prefix . self::TABLE;
try {
$exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
if ( ! $exists ) {
return array(
'ok' => false,
'message' => sprintf( 'hivepress-favorites: shadow table %s missing', $table ),
'details' => array( 'table' => $table ),
);
}
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return array(
'ok' => true,
'message' => sprintf( 'hivepress-favorites: %s rows=%d', $table, $rows ),
'details' => array(
'table' => $table,
'rows' => $rows,
),
);
} catch ( \Throwable $e ) {
return array(
'ok' => false,
'message' => sprintf( 'hivepress-favorites doctor failed: %s', $e->getMessage() ),
);
}
}
/**
* 8-D self-score.
*
* D1: 1.0 — adapter never calls *_meta()
* D2: 1.0 — uses wpdb prefix, no hardcoded table literals
* D3: 1.0 — shadow table covers the model
* D4: 1.0 — expected_columns + indexes registered
* D5: 1.0 — listens to wp_insert_comment for cross-write consistency
* D6: 1.0 — zero options/transients
* D7: 1.0 — only queries own shadow table
* D8: 1.0 — UNIQUE(user_id, listing_id) covering index
*/
public function suitability_score(): array {
return $this->compose_score( array() );
}
/**
* FSM modules this adapter contributes.
*
* @return array<int,string>
*/
public function migrations(): array {
return array( 'comment_hp_favorite' );
}
}
} // end if ( ! class_exists )
@@ -3,16 +3,14 @@
* Marketplace adapter — `hivepress-marketplace` (DETECT-ONLY in v3.0.0).
*
* The marketplace addon turns listings into purchasable items, integrating
* with WooCommerce. It adds:
* with WooCommerce. Its filterable, sortable listing attribute `price` is
* stored as `hp_price` → decimal(10,2), used in price-range filters. It is
* flattened by the `hp_listing_core` entity group
* (TMDO_HivePress_Entity_Groups); the adapter registers no zone fields.
*
* hp_purchase_price → decimal(10,2), used in price-range filters (HOT)
* hp_purchase_count → counter (WARM zone, low-criticality)
*
* Note: `hp_purchase_price` is also conditionally registered by the existing
* legacy hivepress integration (Sprint 1 left that path) — the dedup in
* Schema_Registry is provider+key based, so registering again under the
* `hivepress-marketplace` provider is safe and lets `wp wpdo doctor` show
* the right ownership.
* Its other listing attributes (price tiers / extras / discounts, quantity,
* subscription period, …) are not search filters, and it keeps no per-listing
* sales counter.
*
* Detect-only: not installed in dev environment. Schema reserved for the
* day the addon ships.
@@ -61,63 +59,6 @@ if ( ! class_exists( 'TMDO_HivePress_Marketplace_Adapter' ) ) {
return '1.0.0';
}
/**
* Register marketplace hot fields on the listing post type.
*
* @param TMDO_Schema_Registry $registry Schema registry singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
$registry->register_many(
$this->plugin_slug(),
array(
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_purchase_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2) DEFAULT NULL',
'column' => 'hp_purchase_price',
'indexed' => true,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_purchase_count',
'zone' => 'warm',
'ttl' => null,
),
)
);
}
/**
* Doctor probe — verify hot column exists on listing hot table.
*
* @return array{ok:bool, message:string}
*/
public function doctor_check(): array {
global $wpdb;
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
return array(
'ok' => false,
'message' => 'wpdb global not available',
);
}
$table = $wpdb->prefix . 'wpdo_hot_hp_listing';
try {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
return array(
'ok' => $exists,
'message' => $exists
? 'hivepress-marketplace: shares wp_wpdo_hot_hp_listing'
: 'hivepress-marketplace: hot listing table missing',
);
} catch ( \Throwable $e ) {
return array(
'ok' => false,
'message' => sprintf( 'hivepress-marketplace doctor failed: %s', $e->getMessage() ),
);
}
}
/**
* 8-D self-score.
*
@@ -5,18 +5,13 @@
* Two post-type models:
*
* - Membership_Plan (post_type=hp_membership_plan)
* hot fields: hp_expire_period (int), hp_primary (bool)
* hp_expire_period (int), hp_primary (bool)
*
* - Membership (post_type=hp_membership)
* hot fields: hp_expired_time (unix ts) — checked on every page load
* hp_expired_time (unix ts) — checked on every page load
*
* Both fields registered to Hot zone so:
* - `hp_expired_time < NOW()` lookups become indexed range scans
* (replaces O(N) postmeta scan on each request).
* - `hp_primary = 1` lookups for "default plan" become O(1).
*
* Tables `wp_wpdo_hot_hp_membership_plan` and `wp_wpdo_hot_hp_membership`
* are created by `TMDO_Schema_Manager` from the registered hot columns.
* Flattened by the `hp_membership_plan_core` / `hp_membership_core` entity
* groups (TMDO_HivePress_Entity_Groups); the adapter registers no zone fields.
*
* @package WP_Data_Optimizer
* @since 3.0.0
@@ -62,81 +57,6 @@ if ( ! class_exists( 'TMDO_HivePress_Memberships_Adapter' ) ) {
return '2.0.0';
}
/**
* Register hot fields for both post types.
*
* @param TMDO_Schema_Registry $registry Schema registry singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
$registry->register_many(
$this->plugin_slug(),
array(
// Plan fields.
array(
'post_type' => 'hp_membership_plan',
'meta_key' => 'hp_expire_period',
'zone' => 'hot',
'data_type' => 'int(11) NOT NULL DEFAULT 0',
'column' => 'hp_expire_period',
'indexed' => false,
),
array(
'post_type' => 'hp_membership_plan',
'meta_key' => 'hp_primary',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_primary',
'indexed' => true,
),
// Membership (subscription) field — hottest path: every page load.
array(
'post_type' => 'hp_membership',
'meta_key' => 'hp_expired_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_expired_time',
'indexed' => true,
),
)
);
}
/**
* Doctor probe — verify both hot tables.
*
* @return array{ok:bool, message:string, details?:array<string,mixed>}
*/
public function doctor_check(): array {
global $wpdb;
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
return array(
'ok' => false,
'message' => 'wpdb global not available',
);
}
$details = array();
$ok = true;
foreach ( array( 'hp_membership_plan', 'hp_membership' ) as $pt ) {
$table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $pt );
try {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
$details[ $pt ]['table'] = $table;
$details[ $pt ]['exists'] = $exists;
if ( ! $exists ) {
$ok = false;
}
} catch ( \Throwable $e ) {
$details[ $pt ]['error'] = $e->getMessage();
$ok = false;
}
}
return array(
'ok' => $ok,
'message' => $ok ? 'hivepress-memberships: hot tables OK' : 'hivepress-memberships: hot tables missing',
'details' => $details,
);
}
/**
* 8-D self-score.
*
@@ -152,18 +72,6 @@ if ( ! class_exists( 'TMDO_HivePress_Memberships_Adapter' ) ) {
public function suitability_score(): array {
return $this->compose_score( array() );
}
/**
* FSM modules this adapter contributes.
*
* @return array<int,string>
*/
public function migrations(): array {
return array(
'hot_hp_membership_plan',
'hot_hp_membership',
);
}
}
} // end if ( ! class_exists )
@@ -3,28 +3,12 @@
* Messages adapter — `hivepress-messages`.
*
* The Message model extends Comment (`comment_type = hp_message`) and stores
* the recipient_id in `comment_karma` — a creative re-use of an unused column,
* but it leaves the field unindexed for "messages received by user N"
* queries. Result: every dashboard "unread count" badge does an O(N)
* `WHERE comment_type = 'hp_message' AND comment_karma = N` scan over
* the entire wp_comments table.
* the recipient in `comment_karma`, the sender in `user_id` and the read flag
* in `comment_approved`.
*
* Fix: shadow table `wp_wpdo_comment_hp_message` with proper recipient_id
* column + index. Adapter mirrors every hp_message write into the shadow,
* and the comment-router rewrites recipient/unread queries to hit the index.
*
* Schema:
*
* CREATE TABLE wp_wpdo_comment_hp_message (
* comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
* sender_id BIGINT UNSIGNED NOT NULL,
* recipient_id BIGINT UNSIGNED NOT NULL,
* listing_id BIGINT UNSIGNED NOT NULL,
* is_read TINYINT(1) NOT NULL DEFAULT 0,
* sent_at DATETIME NOT NULL,
* KEY idx_recipient_unread (recipient_id, is_read),
* KEY idx_thread (listing_id, sent_at)
* );
* Nothing is stored in comment meta, so there is no EAV surface to flatten:
* the adapter only reports detection and doctor status. (It used to mirror
* messages into `wpdo_comment_hp_message`, a table nothing ever created.)
*
* @package WP_Data_Optimizer
* @since 3.0.0
@@ -37,18 +21,15 @@ if ( ! defined( 'ABSPATH' ) ) {
if ( ! class_exists( 'TMDO_HivePress_Messages_Adapter' ) ) {
/**
* Messages addon adapter.
* Messages addon adapter (detection and doctor only).
*/
final class TMDO_HivePress_Messages_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/** Comment_type owned by this adapter. */
/** Comment_type of the Message model. */
public const COMMENT_TYPE = 'hp_message';
/** Shadow table name (without wpdb prefix). */
public const TABLE = 'wpdo_comment_hp_message';
/**
* Constructor — bind register hooks via inherited trait.
*/
@@ -75,189 +56,6 @@ if ( ! class_exists( 'TMDO_HivePress_Messages_Adapter' ) ) {
public function minimum_addon_version(): string {
return '1.4.0';
}
/**
* Declare the shadow table to the Custom_Table_Registry.
*
* @param TMDO_Custom_Table_Registry $registry Registry singleton.
*/
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void {
$this->register_custom_table(
$registry,
array(
'table_name' => self::TABLE,
'primary_key' => 'comment_id',
'post_type_link' => null,
'expected_columns' => array(
'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY',
'sender_id' => 'BIGINT UNSIGNED NOT NULL',
'recipient_id' => 'BIGINT UNSIGNED NOT NULL',
'listing_id' => 'BIGINT UNSIGNED NOT NULL',
'is_read' => 'TINYINT(1) NOT NULL DEFAULT 0',
'sent_at' => 'DATETIME NOT NULL',
),
'indexes' => array(
'idx_recipient_unread' => '(recipient_id, is_read)',
'idx_thread' => '(listing_id, sent_at)',
),
'doctor_callback' => array( $this, 'doctor_check' ),
)
);
}
/**
* Bind sync hooks on hp_message comment insert / update / delete.
*
* The recipient_id is stored in comment_karma by HivePress; we mirror
* it into a properly-named indexed column. is_read tracks comment_approved
* (HP uses 0=unread / 1=read) — same column, mirrored.
*/
public function on_register_event_hooks(): void {
add_action( 'wp_insert_comment', array( $this, 'mirror_insert' ), 10, 2 );
add_action( 'wp_set_comment_status', array( $this, 'mirror_status_change' ), 10, 2 );
add_action( 'deleted_comment', array( $this, 'mirror_delete' ), 10, 2 );
}
/**
* Mirror a new hp_message into the shadow table.
*
* @param int $comment_id Comment ID.
* @param object|\WP_Comment $comment Comment object.
*/
public function mirror_insert( int $comment_id, $comment ): void {
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
return;
}
global $wpdb;
$sender_id = (int) ( $comment->user_id ?? 0 );
$recipient_id = (int) ( $comment->comment_karma ?? 0 );
$listing_id = (int) ( $comment->comment_post_ID ?? 0 );
$is_read = (int) ( $comment->comment_approved ?? 0 );
if ( $sender_id <= 0 || $recipient_id <= 0 ) {
// Malformed message — nothing useful to mirror.
return;
}
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
'INSERT INTO `' . $wpdb->prefix . self::TABLE . '` (comment_id, sender_id, recipient_id, listing_id, is_read, sent_at) VALUES (%d, %d, %d, %d, %d, %s) ON DUPLICATE KEY UPDATE is_read = VALUES(is_read)',
$comment_id,
$sender_id,
$recipient_id,
$listing_id,
$is_read,
$comment->comment_date ?? current_time( 'mysql' )
)
);
}
/**
* Update is_read when a message is marked read (comment_approved flip).
*
* @param int $comment_id Comment ID.
* @param string $status New status ('approve', 'hold', 'spam', 'trash').
*/
public function mirror_status_change( int $comment_id, string $status ): void {
global $wpdb;
// Look up the comment to confirm it's hp_message before touching.
if ( ! function_exists( 'get_comment' ) ) {
return;
}
$comment = get_comment( $comment_id );
if ( ! $comment || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
return;
}
$is_read = ( 'approve' === $status ) ? 1 : 0;
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . self::TABLE,
array( 'is_read' => $is_read ),
array( 'comment_id' => $comment_id ),
array( '%d' ),
array( '%d' )
);
}
/**
* Remove shadow row on hp_message deletion.
*
* @param int $comment_id Comment ID.
* @param object|\WP_Comment $comment Comment object.
*/
public function mirror_delete( int $comment_id, $comment ): void {
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
return;
}
global $wpdb;
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . self::TABLE,
array( 'comment_id' => $comment_id ),
array( '%d' )
);
}
/**
* Doctor probe — verify shadow table.
*
* @return array{ok:bool, message:string, details?:array<string,mixed>}
*/
public function doctor_check(): array {
global $wpdb;
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
return array(
'ok' => false,
'message' => 'wpdb global not available',
);
}
$table = $wpdb->prefix . self::TABLE;
try {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
if ( ! $exists ) {
return array(
'ok' => false,
'message' => sprintf( 'hivepress-messages: shadow table %s missing', $table ),
'details' => array( 'table' => $table ),
);
}
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return array(
'ok' => true,
'message' => sprintf( 'hivepress-messages: %s rows=%d', $table, $rows ),
'details' => array(
'table' => $table,
'rows' => $rows,
),
);
} catch ( \Throwable $e ) {
return array(
'ok' => false,
'message' => sprintf( 'hivepress-messages doctor failed: %s', $e->getMessage() ),
);
}
}
/**
* 8-D self-score.
*
* D1: 1.0 — adapter never calls *_meta()
* D2: 1.0 — uses wpdb prefix exclusively
* D3: 1.0 — shadow table covers recipient/unread access
* D4: 1.0 — expected_columns + indexes registered
* D5: 1.0 — listens to wp_insert_comment + status change for sync
* D6: 1.0 — zero options/transients
* D7: 1.0 — only queries own shadow table
* D8: 1.0 — idx_recipient_unread covers the hot dashboard query
*/
public function suitability_score(): array {
return $this->compose_score( array() );
}
/**
* FSM modules this adapter contributes.
*
* @return array<int,string>
*/
public function migrations(): array {
return array( 'comment_hp_message' );
}
}
} // end if ( ! class_exists )
@@ -5,25 +5,13 @@
* Two related models:
*
* - Request (post_type=hp_request)
* hot fields: hp_drafted (bool), hp_expired_time (unix ts)
* — same access pattern as Listing (user dashboard + expiry cron)
* hp_drafted / hp_expired_time are flattened by the `hp_draft_flags`
* and `hp_request_core` entity groups (TMDO_HivePress_Entity_Groups).
*
* - Offer (comment_type=hp_offer, parent post_type=hp_request)
* comment-column based; benefit comes from comment-router using
* (comment_post_ID, comment_type) covering index + dedicated shadow
* table for "approved offers per request" aggregation.
*
* Shadow table for offers (analogous to favorites pattern):
*
* CREATE TABLE wp_wpdo_comment_hp_offer (
* comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
* request_id BIGINT UNSIGNED NOT NULL,
* bidder_id BIGINT UNSIGNED NOT NULL,
* approved TINYINT(1) NOT NULL DEFAULT 0,
* created_at DATETIME NOT NULL,
* KEY idx_request_approved (request_id, approved),
* KEY idx_bidder (bidder_id)
* );
* stored in comment columns only — no comment meta to flatten.
* (The adapter used to mirror offers into `wpdo_comment_hp_offer`,
* a table nothing ever created.)
*
* @package WP_Data_Optimizer
* @since 3.0.0
@@ -45,9 +33,6 @@ if ( ! class_exists( 'TMDO_HivePress_Requests_Adapter' ) ) {
/** Comment_type for the Offer side of Requests. */
public const OFFER_COMMENT_TYPE = 'hp_offer';
/** Shadow table for offers (without wpdb prefix). */
public const OFFER_TABLE = 'wpdo_comment_hp_offer';
/**
* Constructor — bind register hooks via inherited trait.
*/
@@ -74,194 +59,6 @@ if ( ! class_exists( 'TMDO_HivePress_Requests_Adapter' ) ) {
public function minimum_addon_version(): string {
return '1.2.0';
}
/**
* Register hot fields for hp_request post type.
*
* @param TMDO_Schema_Registry $registry Schema registry singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
$registry->register_many(
$this->plugin_slug(),
array(
array(
'post_type' => 'hp_request',
'meta_key' => 'hp_drafted',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_drafted',
'indexed' => true,
),
array(
'post_type' => 'hp_request',
'meta_key' => 'hp_expired_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_expired_time',
'indexed' => true,
),
)
);
}
/**
* Declare offer shadow table.
*
* @param TMDO_Custom_Table_Registry $registry Registry singleton.
*/
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void {
$this->register_custom_table(
$registry,
array(
'table_name' => self::OFFER_TABLE,
'primary_key' => 'comment_id',
'post_type_link' => 'hp_request',
'expected_columns' => array(
'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY',
'request_id' => 'BIGINT UNSIGNED NOT NULL',
'bidder_id' => 'BIGINT UNSIGNED NOT NULL',
'approved' => 'TINYINT(1) NOT NULL DEFAULT 0',
'created_at' => 'DATETIME NOT NULL',
),
'indexes' => array(
'idx_request_approved' => '(request_id, approved)',
'idx_bidder' => '(bidder_id)',
),
'doctor_callback' => array( $this, 'doctor_check' ),
)
);
}
/**
* Mirror new hp_offer comments into the shadow table.
*/
public function on_register_event_hooks(): void {
add_action( 'wp_insert_comment', array( $this, 'mirror_offer_insert' ), 10, 2 );
add_action( 'edit_comment', array( $this, 'mirror_offer_update' ), 10, 2 );
add_action( 'deleted_comment', array( $this, 'mirror_offer_delete' ), 10, 2 );
}
/**
* Insert a hp_offer mirror row.
*
* @param int $comment_id Comment ID.
* @param object|\WP_Comment $comment Comment object.
*/
public function mirror_offer_insert( int $comment_id, $comment ): void {
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::OFFER_COMMENT_TYPE ) {
return;
}
global $wpdb;
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
'INSERT INTO `' . $wpdb->prefix . self::OFFER_TABLE . '` (comment_id, request_id, bidder_id, approved, created_at) VALUES (%d, %d, %d, %d, %s) ON DUPLICATE KEY UPDATE approved = VALUES(approved)',
$comment_id,
(int) ( $comment->comment_post_ID ?? 0 ),
(int) ( $comment->user_id ?? 0 ),
(int) ( $comment->comment_approved ?? 0 ),
$comment->comment_date ?? current_time( 'mysql' )
)
);
}
/**
* Update mirror row when comment is edited (approval flip is the hot path).
*
* @param int $comment_id Comment ID.
* @param array $data Comment data array passed by edit_comment.
*/
public function mirror_offer_update( int $comment_id, $data ): void {
unset( $data );
global $wpdb;
// Re-fetch comment object for accurate state then re-mirror via insert path.
if ( function_exists( 'get_comment' ) ) {
$comment = get_comment( $comment_id );
if ( $comment ) {
$this->mirror_offer_insert( $comment_id, $comment );
}
}
unset( $wpdb );
}
/**
* Remove mirror row on hp_offer deletion.
*
* @param int $comment_id Comment ID.
* @param object|\WP_Comment $comment Comment object.
*/
public function mirror_offer_delete( int $comment_id, $comment ): void {
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::OFFER_COMMENT_TYPE ) {
return;
}
global $wpdb;
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . self::OFFER_TABLE,
array( 'comment_id' => $comment_id ),
array( '%d' )
);
}
/**
* Doctor probe — request hot table + offer shadow table.
*
* @return array{ok:bool, message:string, details?:array<string,mixed>}
*/
public function doctor_check(): array {
global $wpdb;
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
return array(
'ok' => false,
'message' => 'wpdb global not available',
);
}
$details = array();
$ok = true;
foreach ( array(
'hp_request_hot' => $wpdb->prefix . 'wpdo_hot_hp_request',
'hp_offer_shadow' => $wpdb->prefix . self::OFFER_TABLE,
) as $key => $table ) {
try {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
$details[ $key ] = array(
'table' => $table,
'exists' => $exists,
);
if ( ! $exists ) {
$ok = false;
}
} catch ( \Throwable $e ) {
$details[ $key ] = array(
'table' => $table,
'error' => $e->getMessage(),
);
$ok = false;
}
}
return array(
'ok' => $ok,
'message' => $ok ? 'hivepress-requests: tables OK' : 'hivepress-requests: one or more tables missing',
'details' => $details,
);
}
/**
* 8-D self-score.
*/
public function suitability_score(): array {
return $this->compose_score( array() );
}
/**
* FSM modules this adapter contributes.
*
* @return array<int,string>
*/
public function migrations(): array {
return array(
'hot_hp_request',
'comment_hp_offer',
);
}
}
} // end if ( ! class_exists )
@@ -10,14 +10,12 @@
* - approved → comment_approved
* - listing → comment_post_ID
*
* No postmeta is used; no entity-bridge group needed. Hot-path optimization
* comes from `TMDO_HivePress_Comment_Router` rewriting comment-query SQL to
* use `comment_post_ID + comment_type + comment_approved` index, and from
* the `wp_wpdo_comment_hp_review` flat table that WPDO Comment entity bridge
* already maintains (since v2.13.x).
* No postmeta is used; no entity-bridge group needed. Queries use the core
* `wp_comments` indexes on `comment_post_ID` / `comment_type`, and the
* `wp_wpdo_comment_hp_review` flat table is maintained by the WPDO Comment
* entity bridge (since v2.13.x).
*
* This adapter's role:
* - Declare the comment_type to the comment-router
* - Doctor probe of the entity-bridge table
* - Self-report 8-D suitability score
*
@@ -124,7 +122,7 @@ if ( ! class_exists( 'TMDO_HivePress_Reviews_Adapter' ) ) {
* D5: 1.0 — no cross-adapter writes needed yet
* D6: 1.0 — zero options/transients written
* D7: 1.0 — only queries own entity-bridge table
* D8: 1.0 — comment-router uses (comment_post_ID, comment_type) covering index
* D8: 1.0 — reads use the core wp_comments (comment_post_ID, comment_type) indexes
*/
public function suitability_score(): array {
return $this->compose_score( array() );
@@ -118,12 +118,9 @@ if ( ! class_exists( 'TMDO_HivePress_Bootstrap' ) ) {
self::bind_adapter( $slug, $version );
}
// Step 5: register cross-cutting components (router + cron optimizer).
// Both are gated by their own opt-in option and only activate when
// the operator flips it; safe to call here unconditionally.
if ( class_exists( 'TMDO_HivePress_Comment_Router' ) ) {
TMDO_HivePress_Comment_Router::register();
}
// Step 5: register the cron optimizer. It is gated by its own opt-in
// option and only activates when the operator flips it; safe to call
// here unconditionally.
if ( class_exists( 'TMDO_HivePress_Cron_Optimizer' ) ) {
TMDO_HivePress_Cron_Optimizer::register();
}
@@ -1,166 +0,0 @@
<?php
/**
* Comment query router for HivePress comment-type models.
*
* Rewrites `WP_Comment_Query` SQL clauses to JOIN the per-addon shadow tables
* built by Sprint 2 adapters. Currently handles four `comment_type` values:
*
* hp_message → wp_wpdo_comment_hp_message (recipient_id, is_read indexed)
* hp_favorite → wp_wpdo_comment_hp_favorite (UNIQUE user_id+listing_id)
* hp_offer → wp_wpdo_comment_hp_offer (request_id, approved indexed)
* hp_review → wp_wpdo_comment_hp_review (managed by entity-bridge)
*
* Disabled by default. Operator opts in via:
*
* wp option update wpdo_hivepress_comment_router_enabled 1
*
* Once enabled, comment queries with `type` matching one of the above gain a
* shadow-table LEFT JOIN at `comments_clauses` filter time. The JOIN is
* deliberately LEFT so missing shadow rows (during dual_write phase before
* backfill completes) don't drop legitimate comments.
*
* Per Karpathy guideline: this router only handles the four comment_types
* Sprint 2 actually needs — no extension hooks for "future addons".
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Comment_Router' ) ) {
/**
* Comment query rewriter.
*
* Single-instance, registered via static `register()`. Stateless apart from
* the option-cached enabled flag.
*/
final class TMDO_HivePress_Comment_Router {
/** Option key controlling the rewriter. */
public const OPTION_ENABLED = 'wpdo_hivepress_comment_router_enabled';
/**
* Map of comment_type → shadow table (without wpdb prefix).
*
* @var array<string,string>
*/
private const SHADOW_MAP = array(
'hp_message' => 'wpdo_comment_hp_message',
'hp_favorite' => 'wpdo_comment_hp_favorite',
'hp_offer' => 'wpdo_comment_hp_offer',
'hp_review' => 'wpdo_comment_hp_review',
);
/**
* Whether the router is bound this request.
*
* @var bool
*/
private static bool $bound = false;
/**
* Bind the comments_clauses filter (idempotent).
*
* Bootstrap calls this from each adapter's on_register_query_hooks()
* so router activation follows the same gate as adapter binding.
*/
public static function register(): void {
if ( self::$bound ) {
return;
}
if ( ! self::is_enabled() ) {
return;
}
add_filter( 'comments_clauses', array( __CLASS__, 'rewrite' ), 20, 2 );
self::$bound = true;
}
/**
* Reset internal state (test only).
*
* @internal
*/
public static function reset_for_tests(): void {
self::$bound = false;
}
/**
* Whether router is enabled via the operator option.
*/
public static function is_enabled(): bool {
if ( ! function_exists( 'get_option' ) ) {
return false;
}
return (bool) (int) get_option( self::OPTION_ENABLED, 0 );
}
/**
* Filter callback: rewrite comments_clauses for known hp comment types.
*
* @param array $clauses SQL clauses (join, where, fields, ...).
* @param \WP_Comment_Query $query The query object.
* @return array Possibly-modified clauses.
*/
public static function rewrite( array $clauses, $query ): array {
$type = self::query_comment_type( $query );
if ( '' === $type || ! isset( self::SHADOW_MAP[ $type ] ) ) {
return $clauses;
}
global $wpdb;
$shadow = $wpdb->prefix . self::SHADOW_MAP[ $type ];
$alias = 'wpdo_shadow';
// LEFT JOIN keeps row visibility when shadow lags (during backfill).
$join = ( $clauses['join'] ?? '' ) . " LEFT JOIN `{$shadow}` AS {$alias} ON {$alias}.comment_id = {$wpdb->comments}.comment_ID";
// Filter pushdown: hp_message recipient lookup uses comment_karma in HP.
// We rewrite WHERE clauses that filter by comment_karma (= recipient hack)
// to use the indexed shadow column.
$where = (string) ( $clauses['where'] ?? '' );
if ( 'hp_message' === $type && '' !== $where ) {
$where = preg_replace(
'/' . preg_quote( $wpdb->comments, '/' ) . '\.comment_karma\s*=\s*(\d+)/',
$alias . '.recipient_id = $1',
$where
);
}
$clauses['join'] = $join;
$clauses['where'] = $where;
/**
* Filter: wpdo_hivepress_comment_router_clauses
*
* Allows fine-grained tweaks per comment_type. Intended for adapter
* tests + future custom-column lookups.
*
* @param array $clauses Modified clauses.
* @param string $type Matched comment_type.
* @param string $alias Shadow table alias.
*/
return apply_filters( 'wpdo_hivepress_comment_router_clauses', $clauses, $type, $alias );
}
/**
* Extract the comment_type from a WP_Comment_Query.
*
* @param \WP_Comment_Query $query Query object.
*/
private static function query_comment_type( $query ): string {
if ( ! is_object( $query ) || ! isset( $query->query_vars ) ) {
return '';
}
$type = $query->query_vars['type'] ?? '';
if ( is_array( $type ) ) {
$type = reset( $type );
}
return is_string( $type ) ? $type : '';
}
}
} // end if ( ! class_exists )
@@ -10,9 +10,8 @@
*
* Strategy: detect, log, render admin notice with guidance, AND short-circuit
* the WPDO HivePress bootstrap via `wpdo_hivepress_should_bind` filter so
* adapters are NOT bound. Operator must explicitly run
* `wp wpdo hivepress migrate-from-hpct` to switch to WPDO and deactivate
* the legacy plugins.
* adapters are NOT bound. The operator deactivates the legacy plugins to let
* WPDO take over; data kept in their own tables is not imported.
*
* @package WP_Data_Optimizer
* @since 3.0.0
@@ -163,7 +162,7 @@ if ( ! class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
$message = sprintf(
/* translators: %s: comma-separated list of conflicting plugin names. */
__( 'wp-data-optimizer detected legacy HivePress data-layer plugins: %s. WPDO HivePress integration is paused to prevent dual-write corruption. Run `wp wpdo hivepress migrate-from-hpct` to migrate, then deactivate the legacy plugins.', 'tmdo-hivepress' ),
__( 'wp-data-optimizer detected legacy HivePress data-layer plugins: %s. WPDO HivePress integration is paused to prevent dual-write corruption. Deactivate the legacy plugins to let WPDO take over (data in their own tables is not imported).', 'tmdo-hivepress' ),
implode( ', ', $names )
);
@@ -206,7 +205,7 @@ if ( ! class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
}
$message = sprintf(
'Conflict detected: %s. wp-data-optimizer fully supersedes these. Run: wp wpdo hivepress migrate-from-hpct',
'Conflict detected: %s. wp-data-optimizer fully supersedes these. Deactivate them to let WPDO take over.',
implode( ', ', $conflicts )
);
@@ -119,7 +119,7 @@ if ( ! interface_exists( 'TMDO_HivePress_Adapter' ) ) {
*
* Adapters with hot zone fields used in search/filter MUST register a
* `pre_get_posts` / `pre_get_comments` filter that defers to
* `TMDO_HivePress_Query_Router` / `..._Comment_Router`.
* `TMDO_HivePress_Query_Router`.
*
* Called on `init:5` after Schema_Registry is fully populated.
*/
@@ -1,117 +0,0 @@
<?php
/**
* Favorites interceptor for HivePress user favorites.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Favorites interceptor — syncs HivePress user favorites to hpct_favorites table.
*
* Special: operates on user_meta (hp_favorited_listings), not post_meta.
*/
class TMDO_Favorites_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'favorites';
/**
* Registers WordPress hooks for this interceptor.
*
* @return void
*/
public function register_hooks(): void {
add_filter( 'update_user_metadata', array( $this, 'filter_update_user_meta' ), 10, 5 );
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
}
/**
* Filters update_user_metadata to sync hp_favorited_listings.
*
* @param mixed $check Whether to short-circuit.
* @param int $user_id User ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
* @param mixed $prev_value Previous meta value.
* @return mixed Filtered check value.
*/
public function filter_update_user_meta( $check, int $user_id, string $meta_key, $meta_value, $prev_value ) {
if ( 'hp_favorited_listings' !== $meta_key || ! $this->is_active() ) {
return $check;
}
try {
$this->sync_favorites( $user_id, $meta_value );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'update_user_metadata', $e->getMessage() );
}
return $check;
}
/**
* Deletes favorites records when a listing post is deleted.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public function action_delete_post( int $post_id, \WP_Post $post ): void {
if ( 'hp_listing' !== $post->post_type || ! $this->is_active() ) {
return;
}
try {
global $wpdb;
$wpdb->delete( TMDO_DB::table( 'hpct_favorites' ), array( 'listing_id' => $post_id ), array( '%d' ) );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
}
}
/**
* Syncs user favorites to the hpct_favorites table.
*
* @param int $user_id User ID.
* @param mixed $meta_value Favorites meta value (array or serialized).
* @return void
*/
private function sync_favorites( int $user_id, $meta_value ): void {
global $wpdb;
$table = TMDO_DB::table( 'hpct_favorites' );
$now = TMDO_DB::now();
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
// Input is user-controlled wp_usermeta value via update_user_meta hook.
$listing_ids = is_array( $meta_value )
? $meta_value
: (array) TMDO_Safe_Unserialize::run( $meta_value );
$listing_ids = array_filter( array_map( 'absint', $listing_ids ) );
// Delete all existing and re-insert.
$wpdb->delete( $table, array( 'user_id' => $user_id ), array( '%d' ) );
$insert_sql = TMDO_IS_SQLITE ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- insert_sql is 'INSERT IGNORE'/'INSERT OR IGNORE'; table name from TMDO_DB::table().
foreach ( $listing_ids as $listing_id ) {
$wpdb->query(
$wpdb->prepare(
"{$insert_sql} INTO `{$table}` (user_id, listing_id, created_at) VALUES (%d, %d, %s)",
$user_id,
$listing_id,
$now
)
);
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
@@ -1,339 +0,0 @@
<?php
/**
* Listing Meta interceptor for hp_listing post type.
*
* Intercepts get/update_post_metadata for hp_listing posts.
* Operates on hpct_listing_meta KV table. Only intercepts meta_keys
* prefixed with hp_ or _hp_.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Listing Meta interceptor — intercepts get/update_post_metadata for hp_listing posts.
*
* Operates on hpct_listing_meta KV table. Only intercepts meta_keys
* prefixed with hp_ or _hp_.
*/
class TMDO_Listing_Meta_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'listing_meta';
/**
* Registers WordPress hooks for this interceptor.
*
* Skips registration when the underlying `hpct_listing_meta` KV table is
* missing — this prevents cascading DB errors when WPDO is installed
* without HPCT first having been imported. Solves audit finding R-2.
*
* @return void
*/
public function register_hooks(): void {
if ( ! self::table_exists() ) {
// HPCT is not installed at all → don't even log (clean doctor output).
// We only log when HPCT *is* loaded but its table is missing — that's a
// genuine inconsistency worth surfacing to admins.
if ( class_exists( 'HPCT_Core' ) ) {
self::log_skip_once();
}
return;
}
add_filter( 'get_post_metadata', array( $this, 'filter_get_meta' ), 10, 4 );
add_filter( 'update_post_metadata', array( $this, 'filter_update_meta' ), 10, 5 );
add_filter( 'add_post_metadata', array( $this, 'filter_add_meta' ), 10, 5 );
add_filter( 'delete_post_metadata', array( $this, 'filter_delete_meta' ), 10, 5 );
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
}
/**
* Cached check for the existence of the hpct_listing_meta table.
*
* Result is request-cached to avoid repeated SHOW TABLES calls.
*
* @return bool True when the table exists.
*/
private static function table_exists(): bool {
static $exists = null;
if ( null !== $exists ) {
return $exists;
}
global $wpdb;
$table = TMDO_DB::table( 'hpct_listing_meta' );
// SHOW TABLES LIKE returns the table name when present, or NULL when absent.
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
$exists = ( null !== $found && '' !== $found );
return $exists;
}
/**
* Reset cached table_exists state — for tests and PR-3 schema changes.
*
* @internal
*/
public static function reset_table_exists_cache(): void {
// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
// Use reflection to clear the static. Cleanest approach in PHP 8.1+.
( function () {
static $exists = null;
$exists = null;
} )();
// The above closure does not actually reset the bound static of table_exists().
// Instead we expose a flag via a class-level static.
self::$table_exists_cache_invalidated_at = microtime( true );
}
/**
* Marker for cache invalidation. Real reset happens by re-calling table_exists()
* in a fresh process; tests should isolate via runInSeparateProcess where needed.
*
* @var float
*/
private static float $table_exists_cache_invalidated_at = 0.0;
/**
* Records a single "skipped — table missing" entry in wpdo_errors per request.
*
* Direct INSERT (not via TMDO_Logger::error) so we avoid cascading the
* message to PHP's error_log on every page load. Idempotent within a
* single request via static guard.
*
* @return void
*/
private static function log_skip_once(): void {
static $logged = false;
if ( $logged ) {
return;
}
$logged = true;
// Direct INSERT — wrapped in try/catch because the wpdo_errors table
// might not exist in fresh installs. Skipping the log is acceptable;
// breaking register_hooks() is not.
try {
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'wpdo_errors',
array(
'module' => 'listing_meta',
'zone' => '',
'hook' => 'register_hooks',
'message' => 'Skipped: hpct_listing_meta table missing. Run `wp wpdo import-hpct` or migrate first.',
'context' => '{}',
'created_at' => current_time( 'mysql' ),
),
array( '%s', '%s', '%s', '%s', '%s', '%s' )
);
} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- intentional: wpdo_errors absence is non-critical.
// Silently swallow — wpdo_errors table absence is non-critical.
}
}
/**
* Deletes listing meta when a post is deleted.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public function action_delete_post( int $post_id, \WP_Post $post ): void {
if ( 'hp_listing' !== $post->post_type || ! $this->is_active() ) {
return;
}
try {
global $wpdb;
$wpdb->delete( TMDO_DB::table( 'hpct_listing_meta' ), array( 'listing_id' => $post_id ), array( '%d' ) );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
}
}
/**
* Filters get_post_metadata for hp_listing posts.
*
* @param mixed $value Current value or null.
* @param int $object_id Post ID.
* @param string $meta_key Meta key.
* @param bool $single Whether to return single value.
* @return mixed Filtered value.
*/
public function filter_get_meta( $value, int $object_id, string $meta_key, bool $single ) {
if ( ! $this->is_enabled() || ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) ) {
return $value;
}
return $this->intercept(
function () use ( $object_id, $meta_key, $single ) {
global $wpdb;
$table = TMDO_DB::table( 'hpct_listing_meta' );
if ( $single ) {
$val = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name validated by TMDO_DB::table() + sanitize_key().
$object_id,
$meta_key
)
);
return null !== $val ? $val : null;
}
return $wpdb->get_col(
$wpdb->prepare(
"SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name validated by TMDO_DB::table() + sanitize_key().
$object_id,
$meta_key
)
) ?: null;
},
fn() => $value,
'get_post_metadata'
);
}
/**
* Filters update_post_metadata for hp_listing posts.
*
* @param mixed $check Whether to short-circuit.
* @param int $object_id Post ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
* @param mixed $prev_value Previous meta value.
* @return mixed Filtered check value.
*/
public function filter_update_meta( $check, int $object_id, string $meta_key, $meta_value, $prev_value ) {
if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) {
return $check;
}
try {
$this->upsert_meta( $object_id, $meta_key, $meta_value );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() );
}
return $check;
}
/**
* Filters add_post_metadata for hp_listing posts.
*
* @param mixed $check Whether to short-circuit.
* @param int $object_id Post ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
* @param bool $unique Whether meta key should be unique.
* @return mixed Filtered check value.
*/
public function filter_add_meta( $check, int $object_id, string $meta_key, $meta_value, bool $unique ) {
if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) {
return $check;
}
try {
$this->upsert_meta( $object_id, $meta_key, $meta_value );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'add_post_metadata', $e->getMessage() );
}
return $check;
}
/**
* Filters delete_post_metadata for hp_listing posts.
*
* @param mixed $check Whether to short-circuit.
* @param int $object_id Post ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value to match.
* @param bool $delete_all Whether to delete all matching.
* @return mixed Filtered check value.
*/
public function filter_delete_meta( $check, int $object_id, string $meta_key, $meta_value, bool $delete_all ) {
if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) {
return $check;
}
try {
global $wpdb;
$wpdb->delete(
TMDO_DB::table( 'hpct_listing_meta' ),
array(
'listing_id' => $object_id,
'meta_key' => $meta_key,
),
array( '%d', '%s' )
);
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'delete_post_metadata', $e->getMessage() );
}
return $check;
}
/**
* Checks whether the post is an hp_listing.
*
* @param int $post_id Post ID.
* @return bool True if hp_listing post type.
*/
private function is_hp_listing( int $post_id ): bool {
return 'hp_listing' === get_post_type( $post_id );
}
/**
* Checks whether the meta key is an hp_ or _hp_ key.
*
* @param string $meta_key Meta key.
* @return bool True if key starts with hp_ or _hp_.
*/
private function is_hp_key( string $meta_key ): bool {
return str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' );
}
/**
* Inserts or updates a listing meta value in the hpct_listing_meta table.
*
* Single round-trip via TMDO_DB::upsert() — uses ON DUPLICATE KEY UPDATE
* (MySQL) or ON CONFLICT(listing_id, meta_key) DO UPDATE (SQLite).
* Solves audit finding P-C1 (the previous SELECT + INSERT/UPDATE pattern
* cost 2 SQL round-trips per write).
*
* Requires UNIQUE KEY (listing_id, meta_key) on hpct_listing_meta —
* present by design from HPCT v1.0+.
*
* @param int $listing_id Listing post ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
* @return void
*/
private function upsert_meta( int $listing_id, string $meta_key, $meta_value ): void {
// maybe_serialize array values to match WordPress's native postmeta semantics.
$serialized = is_array( $meta_value ) || is_object( $meta_value )
? maybe_serialize( $meta_value )
: (string) $meta_value;
TMDO_DB::upsert(
TMDO_DB::table( 'hpct_listing_meta' ),
array(
'listing_id' => $listing_id,
'meta_key' => $meta_key,
'meta_value' => $serialized,
),
array( 'meta_value' ), // Update only meta_value on conflict.
array( 'listing_id', 'meta_key' ), // Composite unique key.
array( '%d', '%s', '%s' )
);
}
}
@@ -1,76 +0,0 @@
<?php
/**
* Memberships interceptor for HivePress Membership posts.
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Memberships interceptor — intercepts HivePress Membership reads/writes.
*
* Operates on hpct_memberships table.
*/
class TMDO_Memberships_Interceptor extends TMDO_Standard_Post_Interceptor {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'memberships';
/**
* Meta key → flat column mapping.
*/
public const FIELD_MAP = array(
'hp_plan' => 'plan_id',
'hp_user' => 'user_id',
'hp_price' => 'price',
'hp_order' => 'order_id',
'hp_start_date' => 'start_date',
'hp_end_date' => 'end_date',
);
/**
* Returns the WordPress post_type slug handled by this interceptor.
*
* @return string
*/
protected function get_post_type(): string {
return 'hp_membership';
}
/**
* Returns the table key passed to TMDO_DB::table().
*
* @return string
*/
protected function get_table_key(): string {
return 'hpct_memberships';
}
/**
* Builds the INSERT row for a newly created membership post.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param string $now Formatted datetime string.
* @return array{values: array, formats: array}
*/
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
return array(
'values' => array(
'post_id' => $post_id,
'user_id' => (int) $post->post_author,
'status' => $post->post_status,
'created_at' => $now,
'updated_at' => $now,
),
'formats' => array( '%d', '%d', '%s', '%s', '%s' ),
);
}
}
@@ -1,78 +0,0 @@
<?php
/**
* Messages interceptor for HivePress Message posts.
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Messages interceptor — intercepts HivePress Message reads/writes.
*
* Operates on hpct_messages table.
*/
class TMDO_Messages_Interceptor extends TMDO_Standard_Post_Interceptor {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'messages';
/**
* Meta key → flat column mapping.
*/
public const FIELD_MAP = array(
'hp_sender' => 'sender_id',
'hp_recipient' => 'recipient_id',
'hp_listing' => 'listing_id',
'hp_read' => 'is_read',
);
/**
* Returns the WordPress post_type slug handled by this interceptor.
*
* @return string
*/
protected function get_post_type(): string {
return 'hp_message';
}
/**
* Returns the table key passed to TMDO_DB::table().
*
* @return string
*/
protected function get_table_key(): string {
return 'hpct_messages';
}
/**
* Builds the INSERT row for a newly created message post.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param string $now Formatted datetime string.
* @return array{values: array, formats: array}
*/
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
return array(
'values' => array(
'post_id' => $post_id,
'thread_id' => (int) $post->post_parent,
'sender_id' => (int) $post->post_author,
'subject' => sanitize_text_field( $post->post_title ),
'body' => $post->post_content,
'status' => $post->post_status,
'sent_at' => $now,
'created_at' => $now,
'updated_at' => $now,
),
'formats' => array( '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s' ),
);
}
}
@@ -1,74 +0,0 @@
<?php
/**
* Requests interceptor for HivePress Request posts.
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Requests interceptor — intercepts HivePress Request reads/writes.
*
* Operates on hpct_requests table.
*/
class TMDO_Requests_Interceptor extends TMDO_Standard_Post_Interceptor {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'requests';
/**
* Meta key → flat column mapping.
*/
public const FIELD_MAP = array(
'hp_vendor' => 'vendor_id',
'hp_listing' => 'listing_id',
'hp_budget' => 'budget',
);
/**
* Returns the WordPress post_type slug handled by this interceptor.
*
* @return string
*/
protected function get_post_type(): string {
return 'hp_request';
}
/**
* Returns the table key passed to TMDO_DB::table().
*
* @return string
*/
protected function get_table_key(): string {
return 'hpct_requests';
}
/**
* Builds the INSERT row for a newly created request post.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param string $now Formatted datetime string.
* @return array{values: array, formats: array}
*/
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
return array(
'values' => array(
'post_id' => $post_id,
'user_id' => (int) $post->post_author,
'status' => $post->post_status,
'message' => $post->post_content,
'created_at' => $now,
'updated_at' => $now,
),
'formats' => array( '%d', '%d', '%s', '%s', '%s', '%s' ),
);
}
}
@@ -1,116 +0,0 @@
<?php
/**
* Reviews interceptor for HivePress Review posts.
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Reviews interceptor — intercepts HivePress Review reads/writes.
*
* Operates on hpct_reviews table.
*/
class TMDO_Reviews_Interceptor extends TMDO_Standard_Post_Interceptor {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'reviews';
/**
* Meta key → flat column mapping.
*/
public const FIELD_MAP = array(
'hp_rating' => 'rating',
'hp_listing' => 'listing_id',
'_hp_vendor' => 'vendor_id',
);
/**
* Returns the WordPress post_type slug handled by this interceptor.
*
* @return string
*/
protected function get_post_type(): string {
return 'hp_review';
}
/**
* Returns the table key passed to TMDO_DB::table().
*
* @return string
*/
protected function get_table_key(): string {
return 'hpct_reviews';
}
/**
* Registers WordPress hooks, including the extra get_post_metadata hook.
*
* @return void
*/
public function register_hooks(): void {
parent::register_hooks();
add_filter( 'get_post_metadata', array( $this, 'filter_get_meta' ), 10, 4 );
}
/**
* Builds the INSERT row for a newly created review post.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param string $now Formatted datetime string.
* @return array{values: array, formats: array}
*/
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
return array(
'values' => array(
'post_id' => $post_id,
'user_id' => (int) $post->post_author,
'status' => $post->post_status,
'title' => sanitize_text_field( $post->post_title ),
'content' => $post->post_content,
'created_at' => $now,
'updated_at' => $now,
),
'formats' => array( '%d', '%d', '%s', '%s', '%s', '%s', '%s' ),
);
}
/**
* Reads hp_rating directly from the reviews flat table.
*
* @param mixed $value Short-circuit value.
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param bool $single Whether to return a single value.
* @return mixed
*/
public function filter_get_meta( $value, int $post_id, string $meta_key, bool $single ) {
if ( ! $this->is_enabled() || 'hp_rating' !== $meta_key || 'hp_review' !== get_post_type( $post_id ) ) {
return $value;
}
return $this->intercept(
function () use ( $post_id, $single ) {
global $wpdb;
$rating = $wpdb->get_var(
$wpdb->prepare(
'SELECT rating FROM ' . TMDO_DB::table( 'hpct_reviews' ) . ' WHERE post_id = %d LIMIT 1',
$post_id
)
);
return null !== $rating ? ( $single ? $rating : array( $rating ) ) : null;
},
fn() => $value,
'get_post_metadata'
);
}
}
@@ -1,139 +0,0 @@
<?php
/**
* Statistics interceptor for listing view tracking.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Statistics interceptor — tracks listing view events in hpct_statistics.
*
* Special: append-only (INSERT only, no UPDATE). Records events from
* both _hp_views meta update and HivePress listing/viewed action.
*/
class TMDO_Statistics_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'statistics';
/**
* Registers WordPress hooks for this interceptor.
*
* @return void
*/
public function register_hooks(): void {
add_filter( 'update_post_metadata', array( $this, 'filter_view_meta' ), 10, 5 );
add_action( 'hivepress/v1/models/listing/viewed', array( $this, 'action_listing_viewed' ), 10, 1 );
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
}
/**
* Filters update_post_metadata to record a view event when _hp_views is updated.
*
* @param mixed $check Whether to short-circuit.
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
* @param mixed $prev_value Previous meta value.
* @return mixed Filtered check value.
*/
public function filter_view_meta( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) {
if ( '_hp_views' !== $meta_key || 'hp_listing' !== get_post_type( $post_id ) ) {
return $check;
}
if ( ! $this->is_active() ) {
return $check;
}
try {
$this->record_event( $post_id, 'view' );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() );
}
return $check;
}
/**
* Records a view event when a HivePress listing is viewed.
*
* @param mixed $listing Listing object or ID.
* @return void
*/
public function action_listing_viewed( $listing ): void {
if ( ! $this->is_active() ) {
return;
}
$listing_id = is_object( $listing ) && method_exists( $listing, 'get_id' )
? $listing->get_id()
: ( is_numeric( $listing ) ? (int) $listing : 0 );
if ( ! $listing_id ) {
return;
}
try {
$this->record_event( $listing_id, 'hp_action_view' );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'listing_viewed', $e->getMessage() );
}
}
/**
* Deletes statistics records when a listing post is deleted.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public function action_delete_post( int $post_id, \WP_Post $post ): void {
if ( 'hp_listing' !== $post->post_type || ! $this->is_active() ) {
return;
}
try {
global $wpdb;
$wpdb->delete( TMDO_DB::table( 'hpct_statistics' ), array( 'listing_id' => $post_id ), array( '%d' ) );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
}
}
/**
* Records a statistics event to the hpct_statistics table.
*
* @param int $listing_id Listing post ID.
* @param string $event_type Event type identifier.
* @return void
*/
private function record_event( int $listing_id, string $event_type ): void {
global $wpdb;
$user_id = get_current_user_id();
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
$ip_hash = $ip ? hash( 'sha256', $ip ) : '';
$wpdb->insert(
TMDO_DB::table( 'hpct_statistics' ),
array(
'listing_id' => $listing_id,
'event_type' => $event_type,
'user_id' => $user_id,
'ip_hash' => $ip_hash,
'referrer' => substr( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ?? '' ) ), 0, 500 ),
'created_at' => TMDO_DB::now(),
),
array( '%d', '%s', '%d', '%s', '%s', '%s' )
);
}
}
@@ -1,236 +0,0 @@
<?php
/**
* WP_Query interceptor for hp_listing meta queries.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WP_Query interceptor for hp_listing meta queries.
*
* The hpct_listing_meta is a key-value table (like wp_postmeta), so each
* meta_query condition needs its own JOIN alias with a meta_key condition:
*
* LEFT JOIN wp_hpct_listing_meta AS wpdo_lm_0
* ON (wp_posts.ID = wpdo_lm_0.listing_id AND wpdo_lm_0.meta_key = 'hp_price')
* WHERE wpdo_lm_0.meta_value >= 100
*
* All hp_* and _hp_* meta keys are intercepted. Any other meta keys are
* left in the meta_query to be handled by wp_postmeta as usual.
*/
class TMDO_Listing_Meta_Query extends TMDO_Query_Interceptor_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
protected function get_module(): string {
return 'listing_meta';
}
/**
* Returns the post types this interceptor handles.
*
* @return string[] Post type slugs.
*/
protected function get_post_types(): array {
return array( 'hp_listing' );
}
/**
* Returns the custom table name.
*
* @return string Table name without prefix.
*/
protected function get_table(): string {
return 'hpct_listing_meta';
}
/**
* Returns the join column for this KV table.
*
* @return string Join column name.
*/
protected function get_join_column(): string {
return 'listing_id';
}
/**
* Not used — this class overrides pre_get_posts directly.
*
* @return array Empty array.
*/
protected function get_meta_key_map(): array {
return array();
}
// ── Override pre_get_posts for KV-table pattern ───────────────────────
/**
* Intercepts pre_get_posts to handle KV-table meta_query conditions.
*
* @param \WP_Query $query The WP_Query object.
* @return void
*/
public function pre_get_posts( \WP_Query $query ): void {
if ( ! $this->should_intercept( $query ) ) {
return;
}
$raw_meta_query = (array) $query->get( 'meta_query' );
if ( empty( $raw_meta_query ) ) {
return;
}
$our_clauses = array();
$remaining = array();
foreach ( $raw_meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
$remaining[ $k ] = $clause;
continue;
}
if ( $this->is_hp_key( $clause['key'] ) ) {
$our_clauses[] = array(
'meta_key' => $clause['key'],
'value' => $clause['value'] ?? '',
'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ),
'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ),
);
} else {
$remaining[ $k ] = $clause;
}
}
if ( empty( $our_clauses ) ) {
return;
}
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
$remaining['relation'] = $raw_meta_query['relation'];
}
$query->set( 'meta_query', $remaining );
$query->set( $this->query_var(), $our_clauses );
}
// ── Override posts_join for KV-table: one JOIN alias per condition ─────
/**
* Appends LEFT JOINs for each KV-table meta_query condition.
*
* @param string $join Current JOIN SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified JOIN SQL.
*/
public function posts_join( ?string $join, \WP_Query $query ): string {
$join = (string) ( $join ?? '' );
$clauses = $this->get_clauses( $query );
if ( empty( $clauses ) ) {
return $join;
}
global $wpdb;
$table = TMDO_DB::table( $this->get_table() );
$join_col = esc_sql( $this->get_join_column() );
foreach ( $clauses as $i => $clause ) {
$alias = $this->alias_for( $i );
$meta_key = $clause['meta_key'];
if ( false === strpos( $join, "`{$alias}`" ) ) {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table from TMDO_DB::table(); $alias and $join_col are sanitize_key()-validated.
$join .= $wpdb->prepare(
" LEFT JOIN `{$table}` AS `{$alias}`"
. " ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`{$join_col}`"
. " AND `{$alias}`.`meta_key` = %s)",
$meta_key
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
return $join;
}
// ── Override posts_where for KV-table ─────────────────────────────────
/**
* Appends WHERE conditions for KV-table meta_value comparisons.
*
* @param string $where Current WHERE SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified WHERE SQL.
*/
public function posts_where( ?string $where, \WP_Query $query ): string {
$where = (string) ( $where ?? '' );
$clauses = $this->get_clauses( $query );
if ( empty( $clauses ) ) {
return $where;
}
foreach ( $clauses as $i => $clause ) {
$alias = $this->alias_for( $i );
$col = "`{$alias}`.`meta_value`";
$compare = $this->sanitize_compare( $clause['compare'] );
$type = $clause['type'];
$value = $clause['value'];
$where .= $this->build_condition( $col, $compare, $type, $value );
}
return $where;
}
// ── Override posts_groupby ─────────────────────────────────────────────
/**
* Ensures GROUP BY is set when KV-table clauses are active.
*
* @param string $groupby Current GROUP BY SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified GROUP BY SQL.
*/
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
$groupby = (string) ( $groupby ?? '' );
$clauses = $this->get_clauses( $query );
if ( empty( $clauses ) ) {
return $groupby;
}
global $wpdb;
if ( '' === trim( $groupby ) ) {
$groupby = "`{$wpdb->posts}`.`ID`";
}
return $groupby;
}
// ── Helpers ───────────────────────────────────────────────────────────
/**
* Generates a unique SQL alias for a KV-table JOIN at a given index.
*
* @param int $index Zero-based clause index.
* @return string SQL alias string.
*/
private function alias_for( int $index ): string {
return "wpdo_lm_{$index}";
}
/**
* Checks whether a meta key belongs to HivePress (hp_ or _hp_ prefix).
*
* @param string $meta_key Meta key to check.
* @return bool True if the key is an HP key.
*/
private function is_hp_key( string $meta_key ): bool {
return str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' );
}
}
@@ -1,67 +0,0 @@
<?php
/**
* WP_Query interceptor for hp_membership post type.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WP_Query interceptor for hp_membership post type.
*
* Intercepted meta keys:
* hp_plan => plan_id (BIGINT)
* hp_user => user_id (BIGINT)
* hp_price => price (VARCHAR)
* hp_order => order_id (BIGINT)
* hp_start_date => start_date (DATETIME)
* hp_end_date => end_date (DATETIME)
*/
class TMDO_Memberships_Query extends TMDO_Query_Interceptor_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
protected function get_module(): string {
return 'memberships';
}
/**
* Returns the post types this interceptor handles.
*
* @return string[] Post type slugs.
*/
protected function get_post_types(): array {
return array( 'hp_membership' );
}
/**
* Returns the custom table name.
*
* @return string Table name without prefix.
*/
protected function get_table(): string {
return 'hpct_memberships';
}
/**
* Returns the meta_key to column mapping for hpct_memberships.
*
* @return array<string, string> Meta key to column name map.
*/
protected function get_meta_key_map(): array {
return array(
'hp_plan' => 'plan_id',
'hp_user' => 'user_id',
'hp_price' => 'price',
'hp_order' => 'order_id',
'hp_start_date' => 'start_date',
'hp_end_date' => 'end_date',
);
}
}
@@ -1,63 +0,0 @@
<?php
/**
* WP_Query interceptor for hp_message post type.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WP_Query interceptor for hp_message post type.
*
* Intercepted meta keys:
* hp_sender => sender_id (BIGINT)
* hp_recipient => recipient_id (BIGINT)
* hp_listing => listing_id (BIGINT)
* hp_read => is_read (TINYINT)
*/
class TMDO_Messages_Query extends TMDO_Query_Interceptor_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
protected function get_module(): string {
return 'messages';
}
/**
* Returns the post types this interceptor handles.
*
* @return string[] Post type slugs.
*/
protected function get_post_types(): array {
return array( 'hp_message' );
}
/**
* Returns the custom table name.
*
* @return string Table name without prefix.
*/
protected function get_table(): string {
return 'hpct_messages';
}
/**
* Returns the meta_key to column mapping for hpct_messages.
*
* @return array<string, string> Meta key to column name map.
*/
protected function get_meta_key_map(): array {
return array(
'hp_sender' => 'sender_id',
'hp_recipient' => 'recipient_id',
'hp_listing' => 'listing_id',
'hp_read' => 'is_read',
);
}
}
@@ -1,61 +0,0 @@
<?php
/**
* WP_Query interceptor for hp_request post type.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WP_Query interceptor for hp_request post type.
*
* Intercepted meta keys:
* hp_vendor => vendor_id (BIGINT)
* hp_listing => listing_id (BIGINT)
* hp_budget => budget (VARCHAR)
*/
class TMDO_Requests_Query extends TMDO_Query_Interceptor_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
protected function get_module(): string {
return 'requests';
}
/**
* Returns the post types this interceptor handles.
*
* @return string[] Post type slugs.
*/
protected function get_post_types(): array {
return array( 'hp_request' );
}
/**
* Returns the custom table name.
*
* @return string Table name without prefix.
*/
protected function get_table(): string {
return 'hpct_requests';
}
/**
* Returns the meta_key to column mapping for hpct_requests.
*
* @return array<string, string> Meta key to column name map.
*/
protected function get_meta_key_map(): array {
return array(
'hp_vendor' => 'vendor_id',
'hp_listing' => 'listing_id',
'hp_budget' => 'budget',
);
}
}
@@ -1,64 +0,0 @@
<?php
/**
* WP_Query interceptor for hp_review post type.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WP_Query interceptor for hp_review post type.
*
* Rewrites meta_query conditions for known review meta keys to query
* hpct_reviews directly, eliminating wp_postmeta JOIN for review queries.
*
* Intercepted meta keys:
* hp_listing => listing_id (BIGINT)
* _hp_vendor => vendor_id (BIGINT)
* hp_rating => rating (TINYINT)
*/
class TMDO_Reviews_Query extends TMDO_Query_Interceptor_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
protected function get_module(): string {
return 'reviews';
}
/**
* Returns the post types this interceptor handles.
*
* @return string[] Post type slugs.
*/
protected function get_post_types(): array {
return array( 'hp_review' );
}
/**
* Returns the custom table name.
*
* @return string Table name without prefix.
*/
protected function get_table(): string {
return 'hpct_reviews';
}
/**
* Returns the meta_key to column mapping for hpct_reviews.
*
* @return array<string, string> Meta key to column name map.
*/
protected function get_meta_key_map(): array {
return array(
'hp_listing' => 'listing_id',
'_hp_vendor' => 'vendor_id',
'hp_rating' => 'rating',
);
}
}
+7 -2
View File
@@ -24,6 +24,12 @@ if ( ! file_exists( $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_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' );
define( 'TMDO_HIVEPRESS_FILE', dirname( __DIR__ ) . '/2meet-data-optimizer-hivepress-addon.php' );
@@ -38,7 +44,6 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-detector.php',
'includes/hivepress/class-tmdo-hivepress-conflict-guard.php',
'includes/hivepress/class-tmdo-hivepress-bootstrap.php',
'includes/hivepress/class-tmdo-hivepress-comment-router.php',
'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php',
'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php',
'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php',
@@ -46,7 +51,7 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-rest.php',
'includes/class-tmdo-hivepress-transient-filter.php',
'includes/class-tmdo-hivepress-term-comment-fields.php',
'includes/class-tmdo-hpct-import.php',
'includes/class-tmdo-hivepress-entity-groups.php',
'includes/class-tmdo-listing-stats.php',
'admin/class-tmdo-admin-hivepress.php',
'cli/class-tmdo-cli-hivepress.php',
@@ -12,147 +12,17 @@ use PHPUnit\Framework\TestCase;
* Exercises against real MariaDB (via tests/integration/bootstrap.php) the
* scenarios that unit tests can only stub:
*
* - Shadow table CREATE actually works with the declared expected_columns
* - UNIQUE constraint on (user_id, listing_id) really prevents dup favorites
* - mirror_insert() roundtrips through real $wpdb (SQL syntax valid)
* - 7-state FSM lifecycle moves WPDO_Feature_Flags correctly
*
* @group integration
*/
class HivepressIntegrationTest extends TestCase {
/** Test-scope shadow table for favorites. */
private const TABLE_FAVORITE = 'wp_itest_wpdo_comment_hp_favorite';
/** Test-scope shadow table for messages. */
private const TABLE_MESSAGE = 'wp_itest_wpdo_comment_hp_message';
public static function setUpBeforeClass(): void {
// HivePress integration files are loaded at file-top level so the
// Testable* subclass definitions below can resolve their parents at
// parse time. Schema setup happens here.
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_FAVORITE . '`' );
$wpdb->query( "CREATE TABLE `" . self::TABLE_FAVORITE . "` (
comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
listing_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY unique_user_listing (user_id, listing_id),
KEY idx_listing (listing_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_MESSAGE . '`' );
$wpdb->query( "CREATE TABLE `" . self::TABLE_MESSAGE . "` (
comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
sender_id BIGINT UNSIGNED NOT NULL,
recipient_id BIGINT UNSIGNED NOT NULL,
listing_id BIGINT UNSIGNED NOT NULL,
is_read TINYINT(1) NOT NULL DEFAULT 0,
sent_at DATETIME NOT NULL,
KEY idx_recipient_unread (recipient_id, is_read),
KEY idx_thread (listing_id, sent_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" );
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_FAVORITE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_MESSAGE . '`' );
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE_FAVORITE . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE_MESSAGE . '`' );
// Clear FSM module state between tests.
$GLOBALS['_wp_options']['wpdo_features'] = array();
}
// ── Schema tests ────────────────────────────────────────────────────────
public function test_favorite_table_has_unique_user_listing_constraint(): void {
global $wpdb;
$row = $wpdb->get_row( 'SHOW CREATE TABLE `' . self::TABLE_FAVORITE . '`', 'ARRAY_A' );
$this->assertNotNull( $row );
$ddl = $row['Create Table'] ?? '';
$this->assertStringContainsString( 'UNIQUE KEY', $ddl );
$this->assertStringContainsString( 'unique_user_listing', $ddl );
}
public function test_message_table_has_recipient_unread_index(): void {
global $wpdb;
$row = $wpdb->get_row( 'SHOW CREATE TABLE `' . self::TABLE_MESSAGE . '`', 'ARRAY_A' );
$this->assertNotNull( $row );
$ddl = $row['Create Table'] ?? '';
$this->assertStringContainsString( 'idx_recipient_unread', $ddl );
$this->assertStringContainsString( 'recipient_id', $ddl );
}
// ── mirror_insert real roundtrip ────────────────────────────────────────
public function test_favorite_mirror_insert_writes_real_row(): void {
global $wpdb;
$adapter = new WPDO_HivePress_Favorites_Adapter();
$comment = new stdClass();
$comment->comment_type = 'hp_favorite';
$comment->user_id = 11;
$comment->comment_post_ID = 222;
$comment->comment_date = '2026-05-04 12:00:00';
$adapter->mirror_insert( 1001, $comment );
$rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_FAVORITE . '`' );
$this->assertSame( 1, $rows );
$saved = $wpdb->get_row( 'SELECT * FROM `' . self::TABLE_FAVORITE . '` WHERE comment_id = 1001', 'ARRAY_A' );
$this->assertSame( '11', (string) $saved['user_id'] );
$this->assertSame( '222', (string) $saved['listing_id'] );
}
public function test_favorite_unique_constraint_blocks_double_tap(): void {
global $wpdb;
$adapter = new WPDO_HivePress_Favorites_Adapter();
// Two different comment_ids but same user+listing — UNIQUE should win.
$comment = new stdClass();
$comment->comment_type = 'hp_favorite';
$comment->user_id = 7;
$comment->comment_post_ID = 99;
$comment->comment_date = '2026-05-04 12:00:00';
$adapter->mirror_insert( 2001, $comment );
$adapter->mirror_insert( 2002, $comment ); // Race-style duplicate.
$rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_FAVORITE . '` WHERE user_id = 7 AND listing_id = 99' );
$this->assertSame( 1, $rows, 'UNIQUE(user_id, listing_id) must reject duplicate.' );
}
public function test_message_mirror_insert_promotes_recipient_id(): void {
global $wpdb;
$adapter = new WPDO_HivePress_Messages_Adapter();
$comment = new stdClass();
$comment->comment_type = 'hp_message';
$comment->user_id = 5; // sender
$comment->comment_karma = 42; // recipient (HP hack)
$comment->comment_post_ID = 99;
$comment->comment_approved = 0;
$comment->comment_date = '2026-05-04 12:00:00';
$adapter->mirror_insert( 3001, $comment );
$saved = $wpdb->get_row( 'SELECT * FROM `' . self::TABLE_MESSAGE . '` WHERE comment_id = 3001', 'ARRAY_A' );
$this->assertNotNull( $saved );
$this->assertSame( '42', (string) $saved['recipient_id'] );
$this->assertSame( '5', (string) $saved['sender_id'] );
// recipient_unread index supports the dashboard query.
$unread_count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_MESSAGE . '` WHERE recipient_id = 42 AND is_read = 0' );
$this->assertSame( 1, $unread_count );
}
// ── FSM lifecycle ───────────────────────────────────────────────────────
public function test_fsm_lifecycle_progression(): void {
@@ -189,9 +59,3 @@ class HivepressIntegrationTest extends TestCase {
$this->assertSame( 0, $count, 'Optimizer must short-circuit when module is idle.' );
}
}
// Production adapters use `$wpdb->prefix . self::TABLE` to compose the full
// table name. Integration test bootstrap sets prefix to `wp_itest_`, so the
// production mirror_insert() naturally writes to wp_itest_wpdo_comment_hp_*.
// No subclassing or test-double needed — the production code path is exercised
// directly against real MariaDB with isolated prefix.
@@ -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,74 @@ 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 ) );
}
/**
* 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 ──────────────────────────────────────────────────
public function test_count_legacy_postmeta_rows_returns_zero_when_clean(): void {
@@ -1,160 +0,0 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Listing_Meta_Interceptor — PR-3 R-2 + P-C1 fixes.
*
* Verifies:
* 1. R-2: Interceptor SKIPS hook registration when hpct_listing_meta is missing
* (prevents the "wp_hpct_listing_meta doesn't exist" cascading errors that
* forced 2meet-infocards to bypass the interceptor in production).
* 2. P-C1: upsert_meta() uses 1 SQL round-trip via WPDO_DB::upsert().
*
* @covers WPDO_Listing_Meta_Interceptor
*/
class ListingMetaInterceptorTest extends TestCase {
/**
* R-2: When hpct_listing_meta table is missing, register_hooks() must skip
* gracefully and write a single explanatory error log entry.
*
* @runInSeparateProcess
*/
public function test_register_hooks_skips_when_table_missing(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}hpct_listing_meta`" );
// Simulate HPCT_Core being present — register_hooks() only logs the skip
// when HPCT is loaded but its table is missing (genuine inconsistency).
if ( ! class_exists( 'HPCT_Core' ) ) {
// phpcs:ignore Generic.Commenting.InlineComment.InvalidEndChar -- runtime stub
eval( 'class HPCT_Core {}' ); // @phpcs:ignore
}
// Ensure wpdo_errors exists for the skip log; harmless if pre-created.
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}wpdo_errors` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
module varchar(50) NOT NULL DEFAULT '',
zone varchar(10) NOT NULL DEFAULT '',
hook varchar(255) NOT NULL DEFAULT '',
message longtext NOT NULL,
context longtext,
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY idx_module (module)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Snapshot pre-call count of skip messages.
$pre = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_errors` WHERE module = %s AND hook = %s",
'listing_meta',
'register_hooks'
)
);
$interceptor = new WPDO_Listing_Meta_Interceptor();
$interceptor->register_hooks();
$post = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_errors` WHERE module = %s AND hook = %s",
'listing_meta',
'register_hooks'
)
);
$this->assertSame( $pre + 1, $post, 'Expected exactly one new skip message after register_hooks()' );
}
/**
* P-C1: WPDO_DB::upsert() must produce an INSERT ... ON DUPLICATE KEY UPDATE
* statement (single round-trip), and writes must succeed via the composite
* unique key (listing_id, meta_key).
*
* @runInSeparateProcess
*/
public function test_upsert_uses_composite_unique_key(): void {
global $wpdb;
$table = $wpdb->prefix . 'hpct_listing_meta';
// Clean slate.
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
$wpdb->query(
"CREATE TABLE `{$table}` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
listing_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) NOT NULL DEFAULT '',
meta_value longtext,
PRIMARY KEY (id),
UNIQUE KEY ui_listing_meta (listing_id, meta_key(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// First upsert: INSERT.
$result1 = WPDO_DB::upsert(
$table,
array(
'listing_id' => 4242,
'meta_key' => 'hp_price',
'meta_value' => '100.00',
),
array( 'meta_value' ),
array( 'listing_id', 'meta_key' ),
array( '%d', '%s', '%s' )
);
$this->assertNotFalse( $result1 );
// Second upsert: UPDATE via the composite key.
$result2 = WPDO_DB::upsert(
$table,
array(
'listing_id' => 4242,
'meta_key' => 'hp_price',
'meta_value' => '250.50',
),
array( 'meta_value' ),
array( 'listing_id', 'meta_key' ),
array( '%d', '%s', '%s' )
);
$this->assertNotFalse( $result2 );
// Verify only one row exists for (4242, 'hp_price') with the latest value.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s",
4242,
'hp_price'
),
ARRAY_A
);
$this->assertCount( 1, $rows, 'Composite UNIQUE must collapse two upserts into a single row' );
$this->assertSame( '250.50', $rows[0]['meta_value'] );
// Cleanup.
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
/**
* Verify table_exists() returns false when table missing, true after CREATE.
*
* @runInSeparateProcess
*/
public function test_table_exists_detection(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}hpct_listing_meta`" );
// Use reflection to invoke private static.
$ref = new ReflectionClass( WPDO_Listing_Meta_Interceptor::class );
$method = $ref->getMethod( 'table_exists' );
$method->setAccessible( true );
$this->assertFalse( $method->invoke( null ), 'table_exists must return false when table missing' );
}
}
+16 -10
View File
@@ -21,16 +21,29 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) {
. 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;
// 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' );
define( 'TMDO_HIVEPRESS_VERSION', '0.1.0' );
// ── AddOn classes ──────────────────────────────────────────────────────────
// Superset of the unit bootstrap's list: the integration suite also exercises
// the interceptors, which the unit suite does not load.
$tmdo_hp_files = array(
'includes/class-tmdo-hivepress.php',
@@ -39,7 +52,6 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-detector.php',
'includes/hivepress/class-tmdo-hivepress-conflict-guard.php',
'includes/hivepress/class-tmdo-hivepress-bootstrap.php',
'includes/hivepress/class-tmdo-hivepress-comment-router.php',
'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php',
'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php',
'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php',
@@ -47,7 +59,7 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-rest.php',
'includes/class-tmdo-hivepress-transient-filter.php',
'includes/class-tmdo-hivepress-term-comment-fields.php',
'includes/class-tmdo-hpct-import.php',
'includes/class-tmdo-hivepress-entity-groups.php',
'admin/class-tmdo-admin-hivepress.php',
'cli/class-tmdo-cli-hivepress.php',
);
@@ -55,12 +67,6 @@ $tmdo_hp_files = array(
foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/hivepress/adapters/*.php' ) ?: array() as $tmdo_hp_glob ) {
$tmdo_hp_files[] = 'includes/hivepress/adapters/' . basename( $tmdo_hp_glob );
}
foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/interceptors/*.php' ) ?: array() as $tmdo_hp_glob ) {
$tmdo_hp_files[] = 'includes/interceptors/' . basename( $tmdo_hp_glob );
}
foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/query/*.php' ) ?: array() as $tmdo_hp_glob ) {
$tmdo_hp_files[] = 'includes/query/' . basename( $tmdo_hp_glob );
}
foreach ( $tmdo_hp_files as $tmdo_hp_file ) {
$tmdo_hp_path = TMDO_HIVEPRESS_PATH . $tmdo_hp_file;
-29
View File
@@ -33,31 +33,6 @@ class HivePressBookingsAdapterTest extends TestCase {
$this->assertSame( 'HivePress\\Bookings\\Plugin', $this->adapter->detection_class() );
}
public function test_on_register_fields_reserves_booking_hot_columns(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
$start = $registry->get_field( 'hp_booking', 'hp_start_time' );
$this->assertSame( 'hot', $start['zone'] );
$this->assertTrue( (bool) $start['indexed'] );
$end = $registry->get_field( 'hp_booking', 'hp_end_time' );
$this->assertSame( 'hot', $end['zone'] );
$this->assertTrue( (bool) $end['indexed'] );
}
public function test_on_register_fields_uses_real_booking_meta_keys(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
// hivepress-bookings has no guests field; places are stored as hp_quantity.
$this->assertNull( $registry->get_field( 'hp_booking', 'hp_guests' ) );
$quantity = $registry->get_field( 'hp_booking', 'hp_quantity' );
$this->assertNotNull( $quantity );
$this->assertSame( 'hot', $quantity['zone'] );
}
public function test_score_reflects_unverified_perf_dimension(): void {
$score = $this->adapter->suitability_score();
// D8 reduced to 0.9 because schema is best-effort until addon installs.
@@ -65,8 +40,4 @@ class HivePressBookingsAdapterTest extends TestCase {
// Aggregate = (7 × 1.0 + 0.9) / 8 × 10 = 9.875 → 9.88.
$this->assertSame( 9.88, $score['aggregate'] );
}
public function test_migrations_lists_booking_module(): void {
$this->assertSame( array( 'hot_hp_booking' ), $this->adapter->migrations() );
}
}
-92
View File
@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_HivePress_Comment_Router.
*
* @covers WPDO_HivePress_Comment_Router
*/
class HivePressCommentRouterTest extends TestCase {
use WpdbMockTrait;
protected function setUp(): void {
$this->install_wpdb_mock();
WPDO_HivePress_Comment_Router::reset_for_tests();
// Default disabled.
$GLOBALS['_wp_options'][ WPDO_HivePress_Comment_Router::OPTION_ENABLED ] = 0;
}
public function test_disabled_by_default(): void {
$this->assertFalse( WPDO_HivePress_Comment_Router::is_enabled() );
}
public function test_enabled_when_option_set(): void {
$GLOBALS['_wp_options'][ WPDO_HivePress_Comment_Router::OPTION_ENABLED ] = 1;
$this->assertTrue( WPDO_HivePress_Comment_Router::is_enabled() );
}
public function test_register_is_idempotent(): void {
$GLOBALS['_wp_options'][ WPDO_HivePress_Comment_Router::OPTION_ENABLED ] = 1;
WPDO_HivePress_Comment_Router::register();
WPDO_HivePress_Comment_Router::register(); // Second call must be no-op.
$this->assertTrue( true ); // No throw = pass
}
public function test_rewrite_skips_unknown_comment_type(): void {
$query = new stdClass();
$query->query_vars = array( 'type' => 'comment' ); // not in shadow map
$clauses = array( 'join' => '', 'where' => '1=1' );
$out = WPDO_HivePress_Comment_Router::rewrite( $clauses, $query );
$this->assertSame( $clauses, $out );
}
public function test_rewrite_adds_left_join_for_hp_favorite(): void {
$query = new stdClass();
$query->query_vars = array( 'type' => 'hp_favorite' );
$clauses = array( 'join' => '', 'where' => '1=1' );
$out = WPDO_HivePress_Comment_Router::rewrite( $clauses, $query );
$this->assertStringContainsString( 'LEFT JOIN', $out['join'] );
$this->assertStringContainsString( 'wpdo_comment_hp_favorite', $out['join'] );
$this->assertStringContainsString( 'AS wpdo_shadow', $out['join'] );
}
public function test_rewrite_promotes_recipient_for_hp_message(): void {
$query = new stdClass();
$query->query_vars = array( 'type' => 'hp_message' );
$clauses = array(
'join' => '',
'where' => 'wp_comments.comment_karma = 42 AND comment_approved = 0',
);
$out = WPDO_HivePress_Comment_Router::rewrite( $clauses, $query );
// recipient lookup should be rewritten to use shadow column.
$this->assertStringContainsString( 'wpdo_shadow.recipient_id = 42', $out['where'] );
// Other predicates preserved.
$this->assertStringContainsString( 'comment_approved = 0', $out['where'] );
}
public function test_rewrite_handles_array_type(): void {
$query = new stdClass();
$query->query_vars = array( 'type' => array( 'hp_review', 'comment' ) );
$clauses = array( 'join' => '', 'where' => '' );
$out = WPDO_HivePress_Comment_Router::rewrite( $clauses, $query );
// First element of type array should match (hp_review).
$this->assertStringContainsString( 'wpdo_comment_hp_review', $out['join'] );
}
public function test_rewrite_returns_unchanged_when_query_invalid(): void {
$clauses = array( 'join' => '', 'where' => '1=1' );
$out = WPDO_HivePress_Comment_Router::rewrite( $clauses, 'not an object' );
$this->assertSame( $clauses, $out );
}
}
+10
View File
@@ -47,6 +47,16 @@ class HivePressConflictGuardTest extends TestCase {
$this->assertSame( '', $out );
}
public function test_guidance_does_not_point_to_a_missing_command(): void {
// There is no HPCT importer: `wp wpdo hivepress migrate-from-hpct` never
// existed and TMDO_HPCT_Import had no caller once core dropped HPCT.
$root = dirname( __DIR__, 2 );
foreach ( array( 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php', 'admin/class-tmdo-admin-hivepress.php' ) as $file ) {
$this->assertStringNotContainsString( 'migrate-from-hpct', (string) file_get_contents( $root . '/' . $file ), $file );
}
$this->assertFileDoesNotExist( $root . '/includes/class-tmdo-hpct-import.php' );
}
public function test_memo_cache_avoids_repeat_probing(): void {
WPDO_HivePress_Conflict_Guard::reset_for_tests();
$first = WPDO_HivePress_Conflict_Guard::detect_conflicts();
+29 -29
View File
@@ -46,29 +46,10 @@ class HivePressCoreAdapterTest extends TestCase {
$this->assertSame( '1.7.0', $this->adapter->minimum_addon_version() );
}
public function test_migrations_returns_listing_and_vendor_modules(): void {
$mods = $this->adapter->migrations();
$this->assertContains( 'hot_hp_listing', $mods );
$this->assertContains( 'hot_hp_vendor', $mods );
}
public function test_on_register_fields_populates_schema_registry(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
// Listing hot fields.
$drafted = $registry->get_field( 'hp_listing', 'hp_drafted' );
$this->assertIsArray( $drafted );
$this->assertSame( 'hot', $drafted['zone'] );
$this->assertTrue( (bool) $drafted['indexed'] );
$expired = $registry->get_field( 'hp_listing', 'hp_expired_time' );
$this->assertSame( 'hot', $expired['zone'] );
$this->assertStringContainsString( 'bigint', $expired['data_type'] );
$featured_time = $registry->get_field( 'hp_listing', 'hp_featured_time' );
$this->assertSame( 'hot', $featured_time['zone'] );
// Vendor cold blob.
$vendor_image = $registry->get_field( 'hp_vendor', 'hp_image' );
$this->assertIsArray( $vendor_image );
@@ -87,16 +68,6 @@ class HivePressCoreAdapterTest extends TestCase {
}
}
public function test_doctor_check_reports_table_status(): void {
$result = $this->adapter->doctor_check();
$this->assertIsArray( $result );
$this->assertArrayHasKey( 'ok', $result );
$this->assertArrayHasKey( 'message', $result );
$this->assertArrayHasKey( 'details', $result );
// The stub $wpdb returns null for SHOW TABLES, so tables appear missing → ok=false.
$this->assertFalse( $result['ok'] );
}
public function test_optimize_query_extracts_hot_clauses(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
@@ -145,4 +116,33 @@ class HivePressCoreAdapterTest extends TestCase {
$this->adapter->optimize_search( $query, array() );
$this->assertEmpty( $query->get( 'wpdo_hot_clauses' ) );
}
/**
* Register the query hooks with core's post mode set to $mode.
*
* @return array<int, mixed> Callbacks bound to the listing query hooks.
*/
private function query_hooks_in_post_mode( string $mode ): array {
$cache = new ReflectionProperty( TMDO_Mode_Manager::class, 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, array( 'post' => $mode, 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
unset( $GLOBALS['_wp_filter_callbacks']['hivepress/v1/models/listing/query'], $GLOBALS['_wp_filter_callbacks']['hivepress/v1/models/listing/search'] );
try {
$this->adapter->on_register_query_hooks();
} finally {
$cache->setValue( null, null );
}
return array_merge(
$GLOBALS['_wp_filter_callbacks']['hivepress/v1/models/listing/query'] ?? array(),
$GLOBALS['_wp_filter_callbacks']['hivepress/v1/models/listing/search'] ?? array()
);
}
public function test_query_hooks_are_bound_while_the_core_post_router_is_idle(): void {
$this->assertCount( 2, $this->query_hooks_in_post_mode( 'dual_write' ) );
}
public function test_query_hooks_are_left_to_the_core_post_router_when_it_is_active(): void {
$this->assertSame( array(), $this->query_hooks_in_post_mode( 'shadow_read' ), 'two routers would claim the same clauses' );
}
}
+12 -59
View File
@@ -6,6 +6,9 @@ use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_HivePress_Favorites_Adapter.
*
* The adapter no longer mirrors favorites into a shadow table: that table was
* never created, so every mirror write failed.
*
* @covers WPDO_HivePress_Favorites_Adapter
*/
class HivePressFavoritesAdapterTest extends TestCase {
@@ -32,72 +35,22 @@ class HivePressFavoritesAdapterTest extends TestCase {
public function test_constants_exposed(): void {
$this->assertSame( 'hp_favorite', WPDO_HivePress_Favorites_Adapter::COMMENT_TYPE );
$this->assertSame( 'wpdo_comment_hp_favorite', WPDO_HivePress_Favorites_Adapter::TABLE );
}
public function test_on_register_custom_tables_declares_shadow_table(): void {
public function test_registers_no_shadow_table(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->adapter->on_register_custom_tables( $registry );
$tables = $registry->all();
$this->assertCount( 1, $tables );
$cfg = reset( $tables );
$this->assertSame( 'hivepress-favorites', $cfg['provider'] );
$this->assertSame( 'wpdo_comment_hp_favorite', $cfg['table_name'] );
$this->assertSame( 'comment_id', $cfg['primary_key'] );
$this->assertArrayHasKey( 'unique_user_listing', $cfg['indexes'] );
$this->assertStringContainsString( 'UNIQUE', $cfg['indexes']['unique_user_listing'] );
$this->assertSame( array(), $registry->all() );
}
public function test_mirror_insert_skips_non_favorite_comments(): void {
// Stub $wpdb tracks queries.
global $wpdb;
$wpdb->queries = array();
public function test_binds_no_comment_hooks(): void {
$GLOBALS['_wp_filter_callbacks'] = array();
$comment = new stdClass();
$comment->comment_type = 'comment'; // ← not hp_favorite
$comment->user_id = 1;
$comment->comment_post_ID = 99;
$this->adapter->on_register_event_hooks();
$this->adapter->mirror_insert( 123, $comment );
$this->assertSame( array(), $wpdb->queries );
}
public function test_mirror_insert_writes_for_hp_favorite(): void {
global $wpdb;
$wpdb->queries = array();
$comment = new stdClass();
$comment->comment_type = 'hp_favorite';
$comment->user_id = 7;
$comment->comment_post_ID = 42;
$comment->comment_date = '2026-05-04 12:00:00';
$this->adapter->mirror_insert( 100, $comment );
$this->assertCount( 1, $wpdb->queries );
$this->assertStringContainsString( 'INSERT IGNORE', $wpdb->queries[0] );
$this->assertStringContainsString( 'wpdo_comment_hp_favorite', $wpdb->queries[0] );
}
public function test_mirror_insert_skips_when_user_or_listing_missing(): void {
global $wpdb;
$wpdb->queries = array();
$comment = new stdClass();
$comment->comment_type = 'hp_favorite';
$comment->user_id = 0;
$comment->comment_post_ID = 99;
$this->adapter->mirror_insert( 100, $comment );
$this->assertSame( array(), $wpdb->queries );
}
public function test_doctor_check_reports_missing_table_in_stub_env(): void {
$result = $this->adapter->doctor_check();
$this->assertFalse( $result['ok'] );
$this->assertStringContainsString( 'wpdo_comment_hp_favorite', $result['message'] );
$this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
$this->assertArrayNotHasKey( 'deleted_comment', $GLOBALS['_wp_filter_callbacks'] );
}
public function test_score_aggregates_to_perfect_ten(): void {
@@ -105,7 +58,7 @@ class HivePressFavoritesAdapterTest extends TestCase {
$this->assertSame( 10.0, $score['aggregate'] );
}
public function test_migrations_lists_comment_module(): void {
$this->assertSame( array( 'comment_hp_favorite' ), $this->adapter->migrations() );
public function test_migrations_is_empty(): void {
$this->assertSame( array(), $this->adapter->migrations() );
}
}
+232
View File
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* The AddOn owns the HivePress entity groups core used to register.
*
* Their definitions are persisted (wpdo_registry_meta schema hash) and the
* flat tables on live sites were built from them, so they are pinned to the
* persisted hashes: any drift would ALTER flat tables that may hold
* aeav_only data. (Values recorded on dev20, 2026-09-25.)
*/
class HivePressEntityGroupsTest extends TestCase {
private const PERSISTED_HASHES = array(
'post/hp_listing_core' => 'fb5ab760',
'post/hp_request_core' => '27d6f4f5',
'post/hp_vendor_core' => '5366e5c8',
'user/hp_user' => '61ac526f',
);
/**
* Lookups that do not pass a post type (or run with scoped routing off,
* until a site's rows are reconciled) keep the legacy owner of keys shared
* by several post groups: the LAST group registered. Pinned so the
* registration order cannot change silently.
*/
private const SHARED_KEY_OWNERS = array(
'hp_status' => 'hp_request_core',
'hp_expired_time' => 'hp_request_core',
'hp_view_count' => 'hp_request_core',
'hp_verified' => 'hp_vendor_core',
'hp_rating' => 'hp_vendor_core',
'hp_rating_count' => 'hp_vendor_core',
'hp_hourly_rate' => '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 {
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();
}
protected function tearDown(): void {
unset( $GLOBALS['_wp_options'][ TMDO_Entity_Registry::SCOPED_ROUTING_OPTION ] );
unset( $GLOBALS['_wp_filter_callbacks']['tmdo_rest_default_post_type'] );
}
public function test_definitions_match_persisted_schema_hashes(): void {
foreach ( self::PERSISTED_HASHES as $id => $hash ) {
list( $type, $group ) = explode( '/', $id );
$fields = TMDO_Entity_Registry::get_group_fields( $type, $group );
$this->assertNotEmpty( $fields, "{$id} not registered" );
$this->assertSame( $hash, substr( TMDO_Schema_Manager::calculate_schema_hash( $fields ), 0, 8 ), "{$id} drifted from the persisted definition" );
}
}
/**
* 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 {
foreach ( self::SHARED_KEY_OWNERS as $key => $group ) {
$this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key )['group'], $key );
}
}
public function test_shared_keys_follow_the_post_type_when_scoped_routing_is_on(): void {
$GLOBALS['_wp_options'][ TMDO_Entity_Registry::SCOPED_ROUTING_OPTION ] = '1';
foreach ( array( 'hp_listing' => 'hp_listing_core', 'hp_request' => 'hp_request_core', 'hp_vendor' => 'hp_vendor_core' ) as $post_type => $group ) {
foreach ( TMDO_Entity_Registry::get_group_keys( 'post', $group ) as $key ) {
$this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key, $post_type )['group'], "{$post_type}:{$key}" );
}
}
$this->assertNull( TMDO_Entity_Registry::get_field( 'post', 'hp_rating', 'post' ), 'HivePress keys stay native on other post types' );
}
/**
* Fields only the Hot zone used to cover (ADR-003), as their own groups.
*/
private const HOT_ONLY_GROUPS = array(
'hp_draft_flags' => array( array( 'hp_listing', 'hp_request' ), array( 'hp_drafted' ) ),
'hp_booking_core' => array( array( 'hp_booking' ), array( 'hp_start_time', 'hp_end_time', 'hp_quantity' ) ),
'hp_membership_plan_core' => array( array( 'hp_membership_plan' ), array( 'hp_expire_period', 'hp_primary' ) ),
'hp_membership_core' => array( array( 'hp_membership' ), array( 'hp_expired_time' ) ),
);
public function test_hot_only_fields_have_entity_groups_scoped_to_their_post_types(): void {
foreach ( self::HOT_ONLY_GROUPS as $group => list( $post_types, $keys ) ) {
$this->assertSame( $post_types, TMDO_Entity_Registry::get_group_post_types( 'post', $group ), $group );
$this->assertSame( $keys, TMDO_Entity_Registry::get_group_keys( 'post', $group ), $group );
}
}
public function test_hot_only_fields_route_to_their_group_when_scoped_routing_is_on(): void {
$GLOBALS['_wp_options'][ TMDO_Entity_Registry::SCOPED_ROUTING_OPTION ] = '1';
foreach ( self::HOT_ONLY_GROUPS as $group => list( $post_types, $keys ) ) {
foreach ( $post_types as $post_type ) {
foreach ( $keys as $key ) {
$this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key, $post_type )['group'], "{$post_type}:{$key}" );
}
}
}
$this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_expired_time', 'hp_listing' )['group'] );
}
public function test_hot_only_groups_add_no_collisions(): void {
$this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
}
public function test_reconciler_plans_the_misrouted_hivepress_keys(): void {
$moves = array();
foreach ( TMDO_Post_Group_Reconciler::plan() as $m ) {
$moves[] = "{$m['source']}>{$m['target']}:{$m['key']}";
}
sort( $moves );
$this->assertSame(
array(
'hp_request_core>hp_listing_core:hp_expired_time',
'hp_request_core>hp_listing_core:hp_status',
'hp_request_core>hp_listing_core:hp_view_count',
'hp_request_core>hp_membership_core:hp_expired_time',
'hp_vendor_core>hp_listing_core:hp_hourly_rate',
'hp_vendor_core>hp_listing_core:hp_rating',
'hp_vendor_core>hp_listing_core:hp_rating_count',
'hp_vendor_core>hp_listing_core:hp_verified',
'hp_vendor_core>hp_request_core:hp_user',
),
$moves
);
}
public function test_rest_listings_default_to_hivepress_listings(): void {
TMDO_HivePress_Entity_Groups::register();
$this->assertSame( 'hp_listing', TMDO_REST_API::default_post_type() );
}
public function test_core_adapter_does_not_register_a_conflicting_group(): void {
( new TMDO_HivePress_Core_Adapter() )->on_register_entity_fields();
$this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
}
}
-13
View File
@@ -33,19 +33,6 @@ class HivePressMarketplaceAdapterTest extends TestCase {
$this->assertSame( 'HivePress\\Marketplace\\Plugin', $this->adapter->detection_class() );
}
public function test_on_register_fields_registers_hot_price_and_warm_counter(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
$price = $registry->get_field( 'hp_listing', 'hp_purchase_price' );
$this->assertSame( 'hot', $price['zone'] );
$this->assertTrue( (bool) $price['indexed'] );
$this->assertStringContainsString( 'decimal', $price['data_type'] );
$count = $registry->get_field( 'hp_listing', 'hp_purchase_count' );
$this->assertSame( 'warm', $count['zone'] );
}
public function test_score_aggregates_to_nine_eight_eight(): void {
// D8 = 0.9 → aggregate = (7×1.0 + 0.9)/8 × 10 = 9.875 → 9.88.
$this->assertSame( 9.88, $this->adapter->suitability_score()['aggregate'] );
-33
View File
@@ -33,41 +33,8 @@ class HivePressMembershipsAdapterTest extends TestCase {
$this->assertSame( '2.0.0', $this->adapter->minimum_addon_version() );
}
public function test_on_register_fields_registers_three_hot_fields(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
// Plan fields.
$expire_period = $registry->get_field( 'hp_membership_plan', 'hp_expire_period' );
$this->assertSame( 'hot', $expire_period['zone'] );
$this->assertStringContainsString( 'int', $expire_period['data_type'] );
$primary = $registry->get_field( 'hp_membership_plan', 'hp_primary' );
$this->assertSame( 'hot', $primary['zone'] );
$this->assertTrue( (bool) $primary['indexed'] );
// Membership field — the hottest path.
$expired = $registry->get_field( 'hp_membership', 'hp_expired_time' );
$this->assertSame( 'hot', $expired['zone'] );
$this->assertTrue( (bool) $expired['indexed'] );
$this->assertStringContainsString( 'bigint', $expired['data_type'] );
}
public function test_doctor_check_reports_missing_tables_in_stub_env(): void {
$result = $this->adapter->doctor_check();
$this->assertFalse( $result['ok'] );
$this->assertArrayHasKey( 'hp_membership_plan', $result['details'] );
$this->assertArrayHasKey( 'hp_membership', $result['details'] );
}
public function test_score_aggregates_to_perfect_ten(): void {
$score = $this->adapter->suitability_score();
$this->assertSame( 10.0, $score['aggregate'] );
}
public function test_migrations_lists_both_hot_modules(): void {
$mods = $this->adapter->migrations();
$this->assertContains( 'hot_hp_membership_plan', $mods );
$this->assertContains( 'hot_hp_membership', $mods );
}
}
+13 -61
View File
@@ -6,6 +6,9 @@ use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_HivePress_Messages_Adapter.
*
* The adapter no longer mirrors messages into a shadow table: that table was
* never created, so every mirror write failed.
*
* @covers WPDO_HivePress_Messages_Adapter
*/
class HivePressMessagesAdapterTest extends TestCase {
@@ -28,77 +31,26 @@ class HivePressMessagesAdapterTest extends TestCase {
$this->assertSame( 'hivepress-messages', $this->adapter->plugin_slug() );
$this->assertSame( 'HivePress\\Messages\\Plugin', $this->adapter->detection_class() );
$this->assertSame( 'hp_message', WPDO_HivePress_Messages_Adapter::COMMENT_TYPE );
$this->assertSame( 'wpdo_comment_hp_message', WPDO_HivePress_Messages_Adapter::TABLE );
}
public function test_on_register_custom_tables_declares_indexed_recipient(): void {
public function test_registers_no_shadow_table(): void {
$registry = WPDO_Custom_Table_Registry::instance();
$this->adapter->on_register_custom_tables( $registry );
$tables = $registry->all();
$cfg = reset( $tables );
$this->assertSame( 'wpdo_comment_hp_message', $cfg['table_name'] );
$this->assertArrayHasKey( 'recipient_id', $cfg['expected_columns'] );
$this->assertArrayHasKey( 'idx_recipient_unread', $cfg['indexes'] );
$this->assertStringContainsString( 'recipient_id, is_read', $cfg['indexes']['idx_recipient_unread'] );
$this->assertSame( array(), $registry->all() );
}
public function test_mirror_insert_skips_non_message_comments(): void {
global $wpdb;
$wpdb->queries = array();
public function test_binds_no_comment_hooks(): void {
$GLOBALS['_wp_filter_callbacks'] = array();
$comment = new stdClass();
$comment->comment_type = 'comment';
$comment->user_id = 1;
$comment->comment_karma = 5;
$this->adapter->on_register_event_hooks();
$this->adapter->mirror_insert( 1, $comment );
$this->assertSame( array(), $wpdb->queries );
$this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
$this->assertArrayNotHasKey( 'wp_set_comment_status', $GLOBALS['_wp_filter_callbacks'] );
$this->assertArrayNotHasKey( 'deleted_comment', $GLOBALS['_wp_filter_callbacks'] );
}
public function test_mirror_insert_promotes_recipient_from_comment_karma(): void {
global $wpdb;
$wpdb->queries = array();
$comment = new stdClass();
$comment->comment_type = 'hp_message';
$comment->user_id = 7; // sender
$comment->comment_karma = 42; // ← recipient hack lives here
$comment->comment_post_ID = 99;
$comment->comment_approved = 0;
$comment->comment_date = '2026-05-04 12:00:00';
$this->adapter->mirror_insert( 100, $comment );
$this->assertCount( 1, $wpdb->queries );
$sql = $wpdb->queries[0];
$this->assertStringContainsString( 'INSERT INTO', $sql );
$this->assertStringContainsString( 'wpdo_comment_hp_message', $sql );
// The promoted recipient_id should appear in the bound SQL.
$this->assertStringContainsString( '42', $sql );
}
public function test_mirror_insert_skips_when_sender_or_recipient_zero(): void {
global $wpdb;
$wpdb->queries = array();
$comment = new stdClass();
$comment->comment_type = 'hp_message';
$comment->user_id = 0; // bad sender
$comment->comment_karma = 42;
$comment->comment_post_ID = 99;
$this->adapter->mirror_insert( 100, $comment );
$this->assertSame( array(), $wpdb->queries );
}
public function test_doctor_check_reports_missing_table_in_stub_env(): void {
$result = $this->adapter->doctor_check();
$this->assertFalse( $result['ok'] );
$this->assertStringContainsString( 'wpdo_comment_hp_message', $result['message'] );
}
public function test_score_aggregates_to_perfect_ten(): void {
$this->assertSame( 10.0, $this->adapter->suitability_score()['aggregate'] );
public function test_migrations_is_empty(): void {
$this->assertSame( array(), $this->adapter->migrations() );
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* ADR-003: post fields converge on entity groups; the Hot zone is frozen.
*
* No HivePress adapter registers zone=hot fields or contributes hot_* FSM
* modules. The fields they used to cover live in TMDO_HivePress_Entity_Groups.
*/
class NoHotZoneRegistrationTest extends TestCase {
private const ADAPTERS = array(
'TMDO_HivePress_Core_Adapter',
'TMDO_HivePress_Requests_Adapter',
'TMDO_HivePress_Bookings_Adapter',
'TMDO_HivePress_Memberships_Adapter',
'TMDO_HivePress_Marketplace_Adapter',
'TMDO_HivePress_Favorites_Adapter',
'TMDO_HivePress_Reviews_Adapter',
'TMDO_HivePress_Social_Links_Adapter',
'TMDO_HivePress_Blocks_Adapter',
'TMDO_HivePress_Seo_Adapter',
'TMDO_HivePress_Messages_Adapter',
'TMDO_HivePress_Statistics_Adapter',
'TMDO_HivePress_Tags_Adapter',
);
protected function setUp(): void {
$prop = ( new ReflectionClass( TMDO_Schema_Registry::class ) )->getProperty( 'instance' );
$prop->setAccessible( true );
$prop->setValue( null, null );
}
public function test_no_adapter_registers_hot_fields(): void {
$registry = TMDO_Schema_Registry::instance();
foreach ( self::ADAPTERS as $class ) {
( new $class() )->on_register_fields( $registry );
}
$this->assertSame( array(), $registry->get_zone_fields( 'hot' ) );
$this->assertSame( 'cold', $registry->get_field_zone( 'hp_listing', 'hp_images' ), 'cold fields stay' );
}
public function test_no_adapter_contributes_hot_modules(): void {
foreach ( self::ADAPTERS as $class ) {
$this->assertSame( array(), ( new $class() )->migrations(), $class );
}
}
}
+9 -51
View File
@@ -35,67 +35,25 @@ class HivePressRequestsAdapterTest extends TestCase {
$this->assertSame( 'hp_offer', WPDO_HivePress_Requests_Adapter::OFFER_COMMENT_TYPE );
}
public function test_on_register_fields_registers_request_hot_columns(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
$drafted = $registry->get_field( 'hp_request', 'hp_drafted' );
$this->assertSame( 'hot', $drafted['zone'] );
$this->assertTrue( (bool) $drafted['indexed'] );
$expired = $registry->get_field( 'hp_request', 'hp_expired_time' );
$this->assertSame( 'hot', $expired['zone'] );
$this->assertTrue( (bool) $expired['indexed'] );
}
public function test_on_register_custom_tables_declares_offer_shadow(): void {
public function test_registers_no_offer_shadow_table(): void {
// The offer shadow table was never created, so every mirror write failed.
$registry = WPDO_Custom_Table_Registry::instance();
$this->adapter->on_register_custom_tables( $registry );
$tables = $registry->all();
$cfg = reset( $tables );
$this->assertSame( 'wpdo_comment_hp_offer', $cfg['table_name'] );
$this->assertSame( 'hp_request', $cfg['post_type_link'] );
$this->assertArrayHasKey( 'idx_request_approved', $cfg['indexes'] );
$this->assertSame( array(), $registry->all() );
}
public function test_mirror_offer_insert_skips_other_comment_types(): void {
global $wpdb;
$wpdb->queries = array();
public function test_binds_no_comment_hooks(): void {
$GLOBALS['_wp_filter_callbacks'] = array();
$comment = new stdClass();
$comment->comment_type = 'comment'; // ← not hp_offer
$comment->comment_post_ID = 99;
$comment->user_id = 1;
$this->adapter->on_register_event_hooks();
$this->adapter->mirror_offer_insert( 1, $comment );
$this->assertSame( array(), $wpdb->queries );
}
public function test_mirror_offer_insert_writes_for_hp_offer(): void {
global $wpdb;
$wpdb->queries = array();
$comment = new stdClass();
$comment->comment_type = 'hp_offer';
$comment->comment_post_ID = 42;
$comment->user_id = 7;
$comment->comment_approved = 0;
$comment->comment_date = '2026-05-04 12:00:00';
$this->adapter->mirror_offer_insert( 100, $comment );
$this->assertCount( 1, $wpdb->queries );
$this->assertStringContainsString( 'INSERT INTO', $wpdb->queries[0] );
$this->assertStringContainsString( 'wpdo_comment_hp_offer', $wpdb->queries[0] );
$this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
$this->assertArrayNotHasKey( 'edit_comment', $GLOBALS['_wp_filter_callbacks'] );
$this->assertArrayNotHasKey( 'deleted_comment', $GLOBALS['_wp_filter_callbacks'] );
}
public function test_score_aggregates_to_perfect_ten(): void {
$this->assertSame( 10.0, $this->adapter->suitability_score()['aggregate'] );
}
public function test_migrations_lists_request_and_offer_modules(): void {
$mods = $this->adapter->migrations();
$this->assertContains( 'hot_hp_request', $mods );
$this->assertContains( 'comment_hp_offer', $mods );
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ declare(strict_types=1);
* Several existing wpdo tests (ZoneHotTest, SyncBridgeTest, etc.) replace the
* global $wpdb with their own anonymous-class mock and never restore it. When
* our adapter tests run after those, $wpdb lacks the query/get_col/delete
* methods our mirror_insert / cron-optimizer code paths call, causing
* methods our cron-optimizer code paths call, causing
* cross-test pollution failures.
*
* Trait reinstalls a known $wpdb mock + clears the wpdo_features option so