21 Commits

Author SHA1 Message Date
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
wpdev d3ddbb8933 fix(2meet-data-optimizer-hivepress-addon): register enabled social links as hp_{name}
- hivepress-social-links stores each link as hp_{name} (hp_facebook, not
  hp_facebook_url) and lets admins enable links separately for listings
  and vendors; the hardcoded 7 *_url vendor keys never matched real meta
- Read hp_listing_social_links / hp_vendor_social_links and register each
  enabled link on its own post type, so custom links (e.g. line_oa) work
- dev34 check: meta holds hp_instagram / hp_website, and zero *_url keys

Verify: vendor/bin/phpunit --filter HivePressSocialLinksAdapterTest;
  addon unit 156 OK, integration 57 OK
PR: PR-010
2026-09-25 22:35:30 +08:00
wpdev e443f20edb fix(2meet-data-optimizer-hivepress-addon): map bookings hot field to hp_quantity
- hivepress-bookings has no guests field; the places booked are stored as
  hp_quantity (only when "Allow multiple places" is enabled)
- Verified against hivepress-bookings 1.5.5: start_time / end_time were
  already correct and stay unchanged

Verify: vendor/bin/phpunit --filter test_on_register_fields_uses_real_booking_meta_keys;
  addon unit 155 OK, integration 57 OK
PR: PR-009
2026-09-25 22:28:54 +08:00
54 changed files with 1018 additions and 3647 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer — Hivepress AddOn * Plugin Name: 2meet Data Optimizer — Hivepress AddOn
* Plugin URI: https://2meet.io/2meet-data-optimizer/hivepress * Plugin URI: https://2meet.io/2meet-data-optimizer/hivepress
* Description: HivePress + 12 擴充整合:13 adapter (core/reviews/bookings/messages/memberships/requests/favorites/statistics/tags/seo/social-links/blocks/marketplace) + 7 HPCT interceptor + 5 query interceptor + admin tab + CLI namespace * Description: HivePress + 12 擴充整合:13 adapter (core/reviews/bookings/messages/memberships/requests/favorites/statistics/tags/seo/social-links/blocks/marketplace) + 7 HPCT interceptor + 5 query interceptor + admin tab + CLI namespace
* Version: 0.1.0 * Version: 0.3.0
* Requires at least: 6.0 * Requires at least: 6.0
* Tested up to: 6.9.4 * Tested up to: 6.9.4
* Requires PHP: 8.1 * Requires PHP: 8.1
@@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
define( 'TMDO_HIVEPRESS_VERSION', '0.1.0' ); define( 'TMDO_HIVEPRESS_VERSION', '0.3.0' );
define( 'TMDO_HIVEPRESS_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_HIVEPRESS_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_HIVEPRESS_URL', plugin_dir_url( __FILE__ ) ); define( 'TMDO_HIVEPRESS_URL', plugin_dir_url( __FILE__ ) );
define( 'TMDO_HIVEPRESS_FILE', __FILE__ ); define( 'TMDO_HIVEPRESS_FILE', __FILE__ );
+49
View File
@@ -9,6 +9,55 @@ Versioning follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
---
## [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 ### Added
- `tests/integration/`:bootstrap(複用核心的 integration bootstrap,連真實 - `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 ## Requirements
- WordPress ≥ 6.0 - WordPress ≥ 6.0
+3 -3
View File
@@ -85,9 +85,9 @@ if ( ! class_exists( 'TMDO_Admin_HivePress' ) ) {
<tr> <tr>
<td><code><?php echo esc_html( $slug ); ?></code></td> <td><code><?php echo esc_html( $slug ); ?></code></td>
<td><?php echo esc_html( $name ); ?></td> <td><?php echo esc_html( $name ); ?></td>
<td><?php echo $is_detected ? '✅' : '⚪'; ?></td> <td><?php echo esc_html( $is_detected ? '✅' : '⚪' ); ?></td>
<td><?php echo esc_html( $detected[ $slug ] ?? '—' ); ?></td> <td><?php echo esc_html( $detected[ $slug ] ?? '—' ); ?></td>
<td><?php echo $is_bound ? '✅' : '—'; ?></td> <td><?php echo esc_html( $is_bound ? '✅' : '—' ); ?></td>
<td><?php echo null === $score ? '—' : esc_html( (string) $score ); ?></td> <td><?php echo null === $score ? '—' : esc_html( (string) $score ); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
@@ -138,7 +138,7 @@ wp wpdo hivepress benchmark</pre>
. esc_html__( '偵測到衝突外掛:', 'tmdo-hivepress' ) . '</strong> ' . esc_html__( '偵測到衝突外掛:', 'tmdo-hivepress' ) . '</strong> '
. esc_html( implode( ', ', $conflicts ) ) . esc_html( implode( ', ', $conflicts ) )
. '</p><p>' . '</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>'; . '</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( $aliases = array(
'TMDO_Admin_HivePress' => 'WPDO_Admin_HivePress', 'TMDO_Admin_HivePress' => 'WPDO_Admin_HivePress',
'TMDO_CLI_HivePress' => 'WPDO_CLI_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_HP_Bootstrap' => 'WPDO_HP_Bootstrap',
'TMDO_HivePress' => 'WPDO_HivePress', 'TMDO_HivePress' => 'WPDO_HivePress',
'TMDO_HivePress_Attribute_Bridge' => 'WPDO_HivePress_Attribute_Bridge', 'TMDO_HivePress_Attribute_Bridge' => 'WPDO_HivePress_Attribute_Bridge',
@@ -29,9 +27,9 @@ add_action(
'TMDO_HivePress_Blocks_Adapter' => 'WPDO_HivePress_Blocks_Adapter', 'TMDO_HivePress_Blocks_Adapter' => 'WPDO_HivePress_Blocks_Adapter',
'TMDO_HivePress_Bookings_Adapter' => 'WPDO_HivePress_Bookings_Adapter', 'TMDO_HivePress_Bookings_Adapter' => 'WPDO_HivePress_Bookings_Adapter',
'TMDO_HivePress_Bootstrap' => 'WPDO_HivePress_Bootstrap', 'TMDO_HivePress_Bootstrap' => 'WPDO_HivePress_Bootstrap',
'TMDO_HivePress_Comment_Router' => 'WPDO_HivePress_Comment_Router',
'TMDO_HivePress_Conflict_Guard' => 'WPDO_HivePress_Conflict_Guard', 'TMDO_HivePress_Conflict_Guard' => 'WPDO_HivePress_Conflict_Guard',
'TMDO_HivePress_Core_Adapter' => 'WPDO_HivePress_Core_Adapter', '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_Cron_Optimizer' => 'WPDO_HivePress_Cron_Optimizer',
'TMDO_HivePress_Detector' => 'WPDO_HivePress_Detector', 'TMDO_HivePress_Detector' => 'WPDO_HivePress_Detector',
'TMDO_HivePress_Favorites_Adapter' => 'WPDO_HivePress_Favorites_Adapter', 'TMDO_HivePress_Favorites_Adapter' => 'WPDO_HivePress_Favorites_Adapter',
@@ -48,18 +46,7 @@ add_action(
'TMDO_HivePress_Tags_Adapter' => 'WPDO_HivePress_Tags_Adapter', 'TMDO_HivePress_Tags_Adapter' => 'WPDO_HivePress_Tags_Adapter',
'TMDO_Hivepress_Term_Comment_Fields' => 'WPDO_Hivepress_Term_Comment_Fields', 'TMDO_Hivepress_Term_Comment_Fields' => 'WPDO_Hivepress_Term_Comment_Fields',
'TMDO_Hivepress_Transient_Filter' => 'WPDO_Hivepress_Transient_Filter', '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_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 ) { foreach ( $aliases as $tmdo_class => $wpdo_alias ) {
if ( class_exists( $tmdo_class, false ) && ! class_exists( $wpdo_alias, false ) ) { 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. * Bootstrap for 2meet Data Optimizer HivePress AddOn.
* *
* 載入 13 adapter + 7 interceptor + 5 query interceptor + 補助組件 + * 載入 13 adapter + 補助組件 + admin tab + CLI namespace。
* admin tab + CLI namespace。
* *
* 注意:HP 的 V3.0.0 family bootstrap 內部也有一個 class TMDO_HivePress_Bootstrap * 注意:HP 的 V3.0.0 family bootstrap 內部也有一個 class TMDO_HivePress_Bootstrap
* (在 includes/hivepress/class-tmdo-hivepress-bootstrap.php)。為避免衝突,本 * (在 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-detector.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-conflict-guard.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-bootstrap.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-comment-router.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php'; require_once $base . 'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php';
// 13 adapters // 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-benchmark.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-rest.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 // Misc HP integration files
require_once $base . 'includes/class-tmdo-hivepress.php'; require_once $base . 'includes/class-tmdo-hivepress.php';
require_once $base . 'includes/class-tmdo-hivepress-transient-filter.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-term-comment-fields.php';
require_once $base . 'includes/class-tmdo-hivepress-entity-groups.php';
require_once $base . 'includes/class-tmdo-listing-stats.php'; require_once $base . 'includes/class-tmdo-listing-stats.php';
} }
@@ -111,34 +80,6 @@ final class TMDO_HP_Bootstrap {
TMDO_HivePress_Bootstrap::boot(); 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) // HP transient filter (init:5)
if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) { if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 ); 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(); 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 // Admin tab
if ( is_admin() ) { if ( is_admin() ) {
$admin_file = TMDO_HIVEPRESS_PATH . 'admin/class-tmdo-admin-hivepress.php'; $admin_file = TMDO_HIVEPRESS_PATH . 'admin/class-tmdo-admin-hivepress.php';
@@ -0,0 +1,377 @@
<?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();
}
// ── 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' ) )
);
}
/**
* 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,9 @@ final class TMDO_Hivepress_Transient_Filter {
/** Namespace prefix for translated wp_options entries. */ /** Namespace prefix for translated wp_options entries. */
public const TRANSLATED_NAMESPACE = 'wpdo_hp_pm_'; public const TRANSLATED_NAMESPACE = 'wpdo_hp_pm_';
/** Object cache group used instead of wp_options when a persistent cache is present. */
public const CACHE_GROUP = 'tmdo_hp_transient';
/** /**
* Register the four metadata filters on `init`. Idempotent — safe to call * Register the four metadata filters on `init`. Idempotent — safe to call
* multiple times. * multiple times.
@@ -164,7 +167,16 @@ final class TMDO_Hivepress_Transient_Filter {
// HivePress cache layer reads the timeout *first*, then decides whether // HivePress cache layer reads the timeout *first*, then decides whether
// to read the value. We must preserve raw stored values until HivePress // to read the value. We must preserve raw stored values until HivePress
// itself orders deletion. // itself orders deletion.
$value = get_option( $option_name, null ); if ( wp_using_ext_object_cache() ) {
// The object cache is the only store. Rows left in wp_options from
// before are not read (`wp tmdo cleanup-hp-transients --options`
// removes them): a get_option() miss would add the name to
// `notoptions` for good, as later writes never reach wp_options.
$value = wp_cache_get( $option_name, self::CACHE_GROUP );
$value = false === $value ? null : $value;
} else {
$value = get_option( $option_name, null );
}
if ( null === $value ) { if ( null === $value ) {
// Cache miss in our store → fall through to normal postmeta path // Cache miss in our store → fall through to normal postmeta path
@@ -239,16 +251,24 @@ final class TMDO_Hivepress_Transient_Filter {
$translated = self::translate_key( (int) $object_id, (string) $meta_key ); $translated = self::translate_key( (int) $object_id, (string) $meta_key );
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
if ( wp_using_ext_object_cache() ) {
wp_cache_delete( $option_name, self::CACHE_GROUP );
return true;
}
delete_option( $option_name ); delete_option( $option_name );
return true; return true;
} }
/** /**
* Internal: persist value or timeout to wp_options. * Internal: persist value or timeout to the object cache when a persistent
* one is in use (Redis: a TTL cache belongs there, not in wp_options), else
* to wp_options.
* *
* Both halves of the transient pair are written with `autoload=no` (they * In wp_options both halves of the transient pair are written with
* are read on-demand only — never via wp_load_alloptions() — so eagerly * `autoload=no` (they are read on-demand only — never via
* loading them on every page would defeat the point of the move). * wp_load_alloptions() — so eagerly loading them on every page would defeat
* the point of the move). HivePress checks the timeout itself, so the
* cache entries get no TTL of their own.
* *
* @param int $post_id Post ID. * @param int $post_id Post ID.
* @param string $meta_key Original `_transient_*` meta_key. * @param string $meta_key Original `_transient_*` meta_key.
@@ -260,6 +280,10 @@ final class TMDO_Hivepress_Transient_Filter {
$translated = self::translate_key( $post_id, $meta_key ); $translated = self::translate_key( $post_id, $meta_key );
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated; $option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
if ( wp_using_ext_object_cache() ) {
wp_cache_set( $option_name, $meta_value, self::CACHE_GROUP );
return;
}
update_option( $option_name, $meta_value, false ); 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,17 +8,13 @@
* Booking model (post_type=hp_booking) — fields are best-effort from the * Booking model (post_type=hp_booking) — fields are best-effort from the
* addon's documented field aliases: * addon's documented field aliases:
* *
* start_time / end_time → unix timestamp range queries (hot) * start_time / end_time → unix timestamp range queries
* status → enum filter (hot) * quantity → places booked, hp_quantity (only written when
* guests → numeric count (hot for slot-availability) * "Allow multiple places" is on)
* listing → post_parent (already a posts column) * listing → post_parent (already a posts column)
* *
* Hot-zone target: `wp_wpdo_hot_hp_booking` with covering indexes on * These keys are flattened by the `hp_booking_core` entity group
* `(listing_id, start_time)` for slot-availability queries and * (TMDO_HivePress_Entity_Groups); the adapter registers no zone fields.
* `(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.
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -64,73 +60,6 @@ if ( ! class_exists( 'TMDO_HivePress_Bookings_Adapter' ) ) {
return '1.0.0'; 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_guests',
'zone' => 'hot',
'data_type' => 'int(11) NOT NULL DEFAULT 0',
'column' => 'hp_guests',
'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. * 8-D self-score.
* *
@@ -145,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 ) } // end if ( ! class_exists )
@@ -2,15 +2,15 @@
/** /**
* Core HivePress adapter — `hivepress` (the parent plugin). * 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 * - `Listing` (post_type=hp_listing) — cold blob for the gallery.
* (drafted, featured, verified, expired_time, featured_time). * - `Vendor` (post_type=hp_vendor) — cold blob for the image.
* - `Vendor` (post_type=hp_vendor) — Hot column `verified`; cold blob * - `User` (entity=user) — no zone fields; its `hp_user`
* for image / description (display-only). * entity group is registered by TMDO_HivePress_Entity_Groups.
* - `User` (entity=user) — Entity group `hp_user_core` for *
* `hp_verified` and the WP-stock `first_name` / `last_name` / * Search/filter keys are flattened by TMDO_HivePress_Entity_Groups
* `description`. * (ADR-003: no Hot zone fields).
* *
* This adapter SUPERSEDES the legacy `class-tmdo-hivepress.php` (242 lines). * This adapter SUPERSEDES the legacy `class-tmdo-hivepress.php` (242 lines).
* Behavioural parity: * Behavioural parity:
@@ -24,14 +24,9 @@
* adapter only registers `hivepress` core fields. * adapter only registers `hivepress` core fields.
* - Vendor cold fields (`hp_image`, `hp_images`) — preserved. * - Vendor cold fields (`hp_image`, `hp_images`) — preserved.
* *
* NEW additions vs legacy: * (This adapter used to register a second `hp_user` definition —
* * hp_verified / hp_avatar_id, keys HivePress never writes — which the
* - `hp_drafted` (Listing) — Hot tinyint(1) for user-dashboard filtering. * registry silently dropped; TMDO_HivePress_Entity_Groups owns it now.)
* - `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).
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -115,37 +110,10 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
* @param TMDO_Schema_Registry $registry Schema registry singleton. * @param TMDO_Schema_Registry $registry Schema registry singleton.
*/ */
public function on_register_fields( TMDO_Schema_Registry $registry ): void { public function on_register_fields( TMDO_Schema_Registry $registry ): void {
// ── Listing hot fields (search/filter accelerators) ── // ── Cold blobs (display-only) ──
$registry->register_many( $registry->register_many(
$this->plugin_slug(), $this->plugin_slug(),
array( 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). // Vendor cold blob — image attachment id (display-only).
array( array(
'post_type' => 'hp_vendor', '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) ── // ── Query optimization (preserved from legacy class-tmdo-hivepress.php) ──
/** /**
* Bind HivePress search query hooks at priority 20 (after HP setup). * 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 { 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 ) { foreach ( self::HOT_MODELS as $model => $post_type ) {
unset( $post_type ); // Bound below by string-interpolated $model only. unset( $post_type ); // Bound below by string-interpolated $model only.
add_action( add_action(
@@ -318,57 +251,6 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
// ── Doctor / score / migrations ──────────────────────────────────── // ── 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. * 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 ) } // end if ( ! class_exists )
@@ -8,27 +8,9 @@
* - listing → comment_post_ID * - listing → comment_post_ID
* - added_at → comment_date * - added_at → comment_date
* *
* Problem: `wp_comments` has no UNIQUE constraint on (user_id, comment_post_ID, comment_type). * Nothing is stored in comment meta, so there is no EAV surface to flatten:
* A double-tap on the favourite button can race and create two duplicate * the adapter only reports detection and doctor status. (It used to mirror
* favourite rows. HivePress relies on application-level dedup (SELECT before * favorites into `wpdo_comment_hp_favorite`, a table nothing ever created.)
* 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)
* );
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -41,18 +23,15 @@ if ( ! defined( 'ABSPATH' ) ) {
if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) { 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 { final class TMDO_HivePress_Favorites_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait; use TMDO_HivePress_Adapter_Trait;
/** Comment_type owned by this adapter. */ /** Comment_type of the Favorite model. */
public const COMMENT_TYPE = 'hp_favorite'; 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. * Constructor — bind register hooks via inherited trait.
*/ */
@@ -79,156 +58,6 @@ if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) {
public function minimum_addon_version(): string { public function minimum_addon_version(): string {
return '1.2.0'; 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 ) } // end if ( ! class_exists )
@@ -3,16 +3,14 @@
* Marketplace adapter — `hivepress-marketplace` (DETECT-ONLY in v3.0.0). * Marketplace adapter — `hivepress-marketplace` (DETECT-ONLY in v3.0.0).
* *
* The marketplace addon turns listings into purchasable items, integrating * 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) * Its other listing attributes (price tiers / extras / discounts, quantity,
* hp_purchase_count → counter (WARM zone, low-criticality) * subscription period, …) are not search filters, and it keeps no per-listing
* * sales counter.
* 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.
* *
* Detect-only: not installed in dev environment. Schema reserved for the * Detect-only: not installed in dev environment. Schema reserved for the
* day the addon ships. * day the addon ships.
@@ -61,63 +59,6 @@ if ( ! class_exists( 'TMDO_HivePress_Marketplace_Adapter' ) ) {
return '1.0.0'; 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. * 8-D self-score.
* *
@@ -5,18 +5,13 @@
* Two post-type models: * Two post-type models:
* *
* - Membership_Plan (post_type=hp_membership_plan) * - 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) * - 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: * Flattened by the `hp_membership_plan_core` / `hp_membership_core` entity
* - `hp_expired_time < NOW()` lookups become indexed range scans * groups (TMDO_HivePress_Entity_Groups); the adapter registers no zone fields.
* (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.
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -62,81 +57,6 @@ if ( ! class_exists( 'TMDO_HivePress_Memberships_Adapter' ) ) {
return '2.0.0'; 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. * 8-D self-score.
* *
@@ -152,18 +72,6 @@ if ( ! class_exists( 'TMDO_HivePress_Memberships_Adapter' ) ) {
public function suitability_score(): array { public function suitability_score(): array {
return $this->compose_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 ) } // end if ( ! class_exists )
@@ -3,28 +3,12 @@
* Messages adapter — `hivepress-messages`. * Messages adapter — `hivepress-messages`.
* *
* The Message model extends Comment (`comment_type = hp_message`) and stores * 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, * the recipient in `comment_karma`, the sender in `user_id` and the read flag
* but it leaves the field unindexed for "messages received by user N" * in `comment_approved`.
* 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.
* *
* Fix: shadow table `wp_wpdo_comment_hp_message` with proper recipient_id * Nothing is stored in comment meta, so there is no EAV surface to flatten:
* column + index. Adapter mirrors every hp_message write into the shadow, * the adapter only reports detection and doctor status. (It used to mirror
* and the comment-router rewrites recipient/unread queries to hit the index. * messages into `wpdo_comment_hp_message`, a table nothing ever created.)
*
* 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)
* );
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -37,18 +21,15 @@ if ( ! defined( 'ABSPATH' ) ) {
if ( ! class_exists( 'TMDO_HivePress_Messages_Adapter' ) ) { 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 { final class TMDO_HivePress_Messages_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait; use TMDO_HivePress_Adapter_Trait;
/** Comment_type owned by this adapter. */ /** Comment_type of the Message model. */
public const COMMENT_TYPE = 'hp_message'; 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. * Constructor — bind register hooks via inherited trait.
*/ */
@@ -75,189 +56,6 @@ if ( ! class_exists( 'TMDO_HivePress_Messages_Adapter' ) ) {
public function minimum_addon_version(): string { public function minimum_addon_version(): string {
return '1.4.0'; 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 ) } // end if ( ! class_exists )
@@ -5,25 +5,13 @@
* Two related models: * Two related models:
* *
* - Request (post_type=hp_request) * - Request (post_type=hp_request)
* hot fields: hp_drafted (bool), hp_expired_time (unix ts) * hp_drafted / hp_expired_time are flattened by the `hp_draft_flags`
* — same access pattern as Listing (user dashboard + expiry cron) * and `hp_request_core` entity groups (TMDO_HivePress_Entity_Groups).
* *
* - Offer (comment_type=hp_offer, parent post_type=hp_request) * - Offer (comment_type=hp_offer, parent post_type=hp_request)
* comment-column based; benefit comes from comment-router using * stored in comment columns only — no comment meta to flatten.
* (comment_post_ID, comment_type) covering index + dedicated shadow * (The adapter used to mirror offers into `wpdo_comment_hp_offer`,
* table for "approved offers per request" aggregation. * a table nothing ever created.)
*
* 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)
* );
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -45,9 +33,6 @@ if ( ! class_exists( 'TMDO_HivePress_Requests_Adapter' ) ) {
/** Comment_type for the Offer side of Requests. */ /** Comment_type for the Offer side of Requests. */
public const OFFER_COMMENT_TYPE = 'hp_offer'; 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. * Constructor — bind register hooks via inherited trait.
*/ */
@@ -74,194 +59,6 @@ if ( ! class_exists( 'TMDO_HivePress_Requests_Adapter' ) ) {
public function minimum_addon_version(): string { public function minimum_addon_version(): string {
return '1.2.0'; 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 ) } // end if ( ! class_exists )
@@ -10,14 +10,12 @@
* - approved → comment_approved * - approved → comment_approved
* - listing → comment_post_ID * - listing → comment_post_ID
* *
* No postmeta is used; no entity-bridge group needed. Hot-path optimization * No postmeta is used; no entity-bridge group needed. Queries use the core
* comes from `TMDO_HivePress_Comment_Router` rewriting comment-query SQL to * `wp_comments` indexes on `comment_post_ID` / `comment_type`, and the
* use `comment_post_ID + comment_type + comment_approved` index, and from * `wp_wpdo_comment_hp_review` flat table is maintained by the WPDO Comment
* the `wp_wpdo_comment_hp_review` flat table that WPDO Comment entity bridge * entity bridge (since v2.13.x).
* already maintains (since v2.13.x).
* *
* This adapter's role: * This adapter's role:
* - Declare the comment_type to the comment-router
* - Doctor probe of the entity-bridge table * - Doctor probe of the entity-bridge table
* - Self-report 8-D suitability score * - 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 * D5: 1.0 — no cross-adapter writes needed yet
* D6: 1.0 — zero options/transients written * D6: 1.0 — zero options/transients written
* D7: 1.0 — only queries own entity-bridge table * 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 { public function suitability_score(): array {
return $this->compose_score( array() ); return $this->compose_score( array() );
@@ -2,13 +2,13 @@
/** /**
* Social Links adapter — `hivepress-social-links`. * Social Links adapter — `hivepress-social-links`.
* *
* Adds social URL fields to vendor profiles (Facebook, Instagram, Twitter, * The addon adds one attribute per social link the admin enables, separately
* LinkedIn, YouTube, etc.). Stored in postmeta as `hp_social_*` keys. * for listings (`hp_listing_social_links`) and vendors (`hp_vendor_social_links`).
* Each attribute is stored in postmeta as `hp_{name}` (e.g. `hp_facebook`).
* *
* Display-only — never used in search/filter — so the right zone is Cold * Display-only — never used in search/filter — so the right zone is Cold
* (object cache + JSON blob fallback). Listing page reads ~7 social URLs; * (object cache + JSON blob fallback): one blob per post replaces one
* caching them as a single blob keyed on vendor_id eliminates 7 postmeta * postmeta lookup per link on every render.
* lookups per render.
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -27,25 +27,6 @@ if ( ! class_exists( 'TMDO_HivePress_Social_Links_Adapter' ) ) {
use TMDO_HivePress_Adapter_Trait; use TMDO_HivePress_Adapter_Trait;
/**
* Social URL meta keys owned by this addon.
*
* Sources (HivePress 1.0.3 default field set):
* facebook_url, instagram_url, twitter_url, linkedin_url,
* youtube_url, telegram_url, whatsapp_url
*
* @var array<int,string>
*/
private const SOCIAL_KEYS = array(
'hp_facebook_url',
'hp_instagram_url',
'hp_twitter_url',
'hp_linkedin_url',
'hp_youtube_url',
'hp_telegram_url',
'hp_whatsapp_url',
);
/** /**
* Constructor — bind register hooks via inherited trait. * Constructor — bind register hooks via inherited trait.
*/ */
@@ -74,24 +55,44 @@ if ( ! class_exists( 'TMDO_HivePress_Social_Links_Adapter' ) ) {
} }
/** /**
* Register vendor social URL fields to Cold zone. * Register the enabled social link fields to the Cold zone.
* *
* @param TMDO_Schema_Registry $registry Schema registry singleton. * @param TMDO_Schema_Registry $registry Schema registry singleton.
*/ */
public function on_register_fields( TMDO_Schema_Registry $registry ): void { public function on_register_fields( TMDO_Schema_Registry $registry ): void {
$fields = array(); $fields = array();
foreach ( self::SOCIAL_KEYS as $key ) { foreach ( $this->enabled_meta_keys() as $post_type => $keys ) {
$fields[] = array( foreach ( $keys as $key ) {
'post_type' => 'hp_vendor', $fields[] = array(
'meta_key' => $key, 'post_type' => $post_type,
'zone' => 'cold', 'meta_key' => $key,
'cache_group' => 'wpdo_cold_hp_vendor', 'zone' => 'cold',
'cache_ttl' => HOUR_IN_SECONDS, 'cache_group' => 'wpdo_cold_' . $post_type,
); 'cache_ttl' => HOUR_IN_SECONDS,
);
}
} }
$registry->register_many( $this->plugin_slug(), $fields ); $registry->register_many( $this->plugin_slug(), $fields );
} }
/**
* Meta keys of the social links enabled in the addon settings.
*
* @return array<string, array<int, string>> post_type => meta keys.
*/
private function enabled_meta_keys(): array {
$keys = array();
foreach ( array( 'listing', 'vendor' ) as $model ) {
foreach ( (array) get_option( 'hp_' . $model . '_social_links', array() ) as $name ) {
$name = sanitize_key( (string) $name );
if ( '' !== $name ) {
$keys[ 'hp_' . $model ][] = 'hp_' . $name;
}
}
}
return $keys;
}
/** /**
* Doctor probe — cold zone uses cache; verify cache layer is reachable. * Doctor probe — cold zone uses cache; verify cache layer is reachable.
* *
@@ -100,7 +101,7 @@ if ( ! class_exists( 'TMDO_HivePress_Social_Links_Adapter' ) ) {
public function doctor_check(): array { public function doctor_check(): array {
return array( return array(
'ok' => true, 'ok' => true,
'message' => sprintf( 'hivepress-social-links: %d cold fields registered', count( self::SOCIAL_KEYS ) ), 'message' => sprintf( 'hivepress-social-links: %d cold fields registered', array_sum( array_map( 'count', $this->enabled_meta_keys() ) ) ),
); );
} }
@@ -118,12 +118,9 @@ if ( ! class_exists( 'TMDO_HivePress_Bootstrap' ) ) {
self::bind_adapter( $slug, $version ); self::bind_adapter( $slug, $version );
} }
// Step 5: register cross-cutting components (router + cron optimizer). // Step 5: register the cron optimizer. It is gated by its own opt-in
// Both are gated by their own opt-in option and only activate when // option and only activates when the operator flips it; safe to call
// the operator flips it; safe to call here unconditionally. // here unconditionally.
if ( class_exists( 'TMDO_HivePress_Comment_Router' ) ) {
TMDO_HivePress_Comment_Router::register();
}
if ( class_exists( 'TMDO_HivePress_Cron_Optimizer' ) ) { if ( class_exists( 'TMDO_HivePress_Cron_Optimizer' ) ) {
TMDO_HivePress_Cron_Optimizer::register(); 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 * Strategy: detect, log, render admin notice with guidance, AND short-circuit
* the WPDO HivePress bootstrap via `wpdo_hivepress_should_bind` filter so * the WPDO HivePress bootstrap via `wpdo_hivepress_should_bind` filter so
* adapters are NOT bound. Operator must explicitly run * adapters are NOT bound. The operator deactivates the legacy plugins to let
* `wp wpdo hivepress migrate-from-hpct` to switch to WPDO and deactivate * WPDO take over; data kept in their own tables is not imported.
* the legacy plugins.
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -163,7 +162,7 @@ if ( ! class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
$message = sprintf( $message = sprintf(
/* translators: %s: comma-separated list of conflicting plugin names. */ /* 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 ) implode( ', ', $names )
); );
@@ -206,7 +205,7 @@ if ( ! class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
} }
$message = sprintf( $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 ) implode( ', ', $conflicts )
); );
@@ -119,7 +119,7 @@ if ( ! interface_exists( 'TMDO_HivePress_Adapter' ) ) {
* *
* Adapters with hot zone fields used in search/filter MUST register a * Adapters with hot zone fields used in search/filter MUST register a
* `pre_get_posts` / `pre_get_comments` filter that defers to * `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. * 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',
);
}
}
+1 -2
View File
@@ -38,7 +38,6 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-detector.php', 'includes/hivepress/class-tmdo-hivepress-detector.php',
'includes/hivepress/class-tmdo-hivepress-conflict-guard.php', 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php',
'includes/hivepress/class-tmdo-hivepress-bootstrap.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-cron-optimizer.php',
'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php', 'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php',
'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php', 'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php',
@@ -46,7 +45,7 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-rest.php', 'includes/hivepress/class-tmdo-hivepress-rest.php',
'includes/class-tmdo-hivepress-transient-filter.php', 'includes/class-tmdo-hivepress-transient-filter.php',
'includes/class-tmdo-hivepress-term-comment-fields.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', 'includes/class-tmdo-listing-stats.php',
'admin/class-tmdo-admin-hivepress.php', 'admin/class-tmdo-admin-hivepress.php',
'cli/class-tmdo-cli-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 * Exercises against real MariaDB (via tests/integration/bootstrap.php) the
* scenarios that unit tests can only stub: * 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 * - 7-state FSM lifecycle moves WPDO_Feature_Flags correctly
* *
* @group integration * @group integration
*/ */
class HivepressIntegrationTest extends TestCase { 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 { 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. // Clear FSM module state between tests.
$GLOBALS['_wp_options']['wpdo_features'] = array(); $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 ─────────────────────────────────────────────────────── // ── FSM lifecycle ───────────────────────────────────────────────────────
public function test_fsm_lifecycle_progression(): void { 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.' ); $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; global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' ); $wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
// Reset wp_options state for filter tests // Reset wp_options / object cache state for filter tests
$GLOBALS['_wp_options'] = array(); $GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cache'] = array();
$GLOBALS['_wp_using_ext_object_cache'] = false;
// Default the toggle to enabled // Default the toggle to enabled
update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '1' ); update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '1' );
@@ -195,6 +197,46 @@ class HivepressTransientFilterTest extends TestCase {
$this->assertSame( array( array( 'serialized', 'data' ) ), $value ); $this->assertSame( array( array( 'serialized', 'data' ) ), $value );
} }
// ── External object cache (2.2 PR-081) ──────────────────────────────────
public function test_with_an_object_cache_writes_skip_wp_options(): void {
$GLOBALS['_wp_using_ext_object_cache'] = true;
$key = '_transient_hp_models/cat/v1';
$translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key );
$this->assertTrue( WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' ) );
WPDO_Hivepress_Transient_Filter::on_update( null, 100, '_transient_timeout_hp_models/cat/v1', '99999999', '' );
$this->assertFalse( get_option( '_transient_' . $translated ), 'no wp_options row' );
$this->assertFalse( get_option( '_transient_timeout_' . $translated ), 'no wp_options row' );
$this->assertSame( array( 'val' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) );
$this->assertSame( array( '99999999' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, '_transient_timeout_hp_models/cat/v1', true ) );
}
public function test_with_an_object_cache_a_value_left_in_wp_options_is_not_read(): void {
// A miss must not fall back to get_option(): the option is absent after
// cleanup and WordPress would add its name to `notoptions` for good.
$key = '_transient_hp_models/cat/v1';
$translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key );
update_option( '_transient_' . $translated, 'from-before' );
$GLOBALS['_wp_using_ext_object_cache'] = true;
$this->assertNull( WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) );
}
public function test_with_an_object_cache_delete_leaves_wp_options_alone(): void {
$key = '_transient_hp_models/cat/v1';
$translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key );
update_option( '_transient_' . $translated, 'from-before' );
$GLOBALS['_wp_using_ext_object_cache'] = true;
WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' );
WPDO_Hivepress_Transient_Filter::on_delete( null, 100, $key, '', false );
$this->assertNull( WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) );
$this->assertSame( 'from-before', get_option( '_transient_' . $translated ), 'left for cleanup-hp-transients --options' );
}
// ── count/purge legacy ────────────────────────────────────────────────── // ── count/purge legacy ──────────────────────────────────────────────────
public function test_count_legacy_postmeta_rows_returns_zero_when_clean(): void { public function test_count_legacy_postmeta_rows_returns_zero_when_clean(): void {
@@ -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' );
}
}
+8 -10
View File
@@ -23,14 +23,19 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) {
} }
require_once $tmdo_core_bootstrap; require_once $tmdo_core_bootstrap;
// External object cache switch for the transient filter tests (Redis on real sites).
if ( ! function_exists( 'wp_using_ext_object_cache' ) ) {
function wp_using_ext_object_cache( $using = null ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return ! empty( $GLOBALS['_wp_using_ext_object_cache'] );
}
}
define( 'TMDO_HIVEPRESS_PATH', dirname( __DIR__, 2 ) . '/' ); define( 'TMDO_HIVEPRESS_PATH', dirname( __DIR__, 2 ) . '/' );
define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' ); define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' );
define( 'TMDO_HIVEPRESS_FILE', TMDO_HIVEPRESS_PATH . '2meet-data-optimizer-hivepress-addon.php' ); define( 'TMDO_HIVEPRESS_FILE', TMDO_HIVEPRESS_PATH . '2meet-data-optimizer-hivepress-addon.php' );
define( 'TMDO_HIVEPRESS_VERSION', '0.1.0' ); define( 'TMDO_HIVEPRESS_VERSION', '0.1.0' );
// ── AddOn classes ────────────────────────────────────────────────────────── // ── 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( $tmdo_hp_files = array(
'includes/class-tmdo-hivepress.php', 'includes/class-tmdo-hivepress.php',
@@ -39,7 +44,6 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-detector.php', 'includes/hivepress/class-tmdo-hivepress-detector.php',
'includes/hivepress/class-tmdo-hivepress-conflict-guard.php', 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php',
'includes/hivepress/class-tmdo-hivepress-bootstrap.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-cron-optimizer.php',
'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php', 'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php',
'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php', 'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php',
@@ -47,7 +51,7 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-rest.php', 'includes/hivepress/class-tmdo-hivepress-rest.php',
'includes/class-tmdo-hivepress-transient-filter.php', 'includes/class-tmdo-hivepress-transient-filter.php',
'includes/class-tmdo-hivepress-term-comment-fields.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', 'admin/class-tmdo-admin-hivepress.php',
'cli/class-tmdo-cli-hivepress.php', 'cli/class-tmdo-cli-hivepress.php',
); );
@@ -55,12 +59,6 @@ $tmdo_hp_files = array(
foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/hivepress/adapters/*.php' ) ?: array() as $tmdo_hp_glob ) { foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/hivepress/adapters/*.php' ) ?: array() as $tmdo_hp_glob ) {
$tmdo_hp_files[] = 'includes/hivepress/adapters/' . basename( $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 ) { foreach ( $tmdo_hp_files as $tmdo_hp_file ) {
$tmdo_hp_path = TMDO_HIVEPRESS_PATH . $tmdo_hp_file; $tmdo_hp_path = TMDO_HIVEPRESS_PATH . $tmdo_hp_file;
-17
View File
@@ -33,19 +33,6 @@ class HivePressBookingsAdapterTest extends TestCase {
$this->assertSame( 'HivePress\\Bookings\\Plugin', $this->adapter->detection_class() ); $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_score_reflects_unverified_perf_dimension(): void { public function test_score_reflects_unverified_perf_dimension(): void {
$score = $this->adapter->suitability_score(); $score = $this->adapter->suitability_score();
// D8 reduced to 0.9 because schema is best-effort until addon installs. // D8 reduced to 0.9 because schema is best-effort until addon installs.
@@ -53,8 +40,4 @@ class HivePressBookingsAdapterTest extends TestCase {
// Aggregate = (7 × 1.0 + 0.9) / 8 × 10 = 9.875 → 9.88. // Aggregate = (7 × 1.0 + 0.9) / 8 × 10 = 9.875 → 9.88.
$this->assertSame( 9.88, $score['aggregate'] ); $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 ); $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 { public function test_memo_cache_avoids_repeat_probing(): void {
WPDO_HivePress_Conflict_Guard::reset_for_tests(); WPDO_HivePress_Conflict_Guard::reset_for_tests();
$first = WPDO_HivePress_Conflict_Guard::detect_conflicts(); $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() ); $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 { public function test_on_register_fields_populates_schema_registry(): void {
$registry = WPDO_Schema_Registry::instance(); $registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry ); $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 cold blob.
$vendor_image = $registry->get_field( 'hp_vendor', 'hp_image' ); $vendor_image = $registry->get_field( 'hp_vendor', 'hp_image' );
$this->assertIsArray( $vendor_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 { public function test_optimize_query_extracts_hot_clauses(): void {
$registry = WPDO_Schema_Registry::instance(); $registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry ); $this->adapter->on_register_fields( $registry );
@@ -145,4 +116,33 @@ class HivePressCoreAdapterTest extends TestCase {
$this->adapter->optimize_search( $query, array() ); $this->adapter->optimize_search( $query, array() );
$this->assertEmpty( $query->get( 'wpdo_hot_clauses' ) ); $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. * 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 * @covers WPDO_HivePress_Favorites_Adapter
*/ */
class HivePressFavoritesAdapterTest extends TestCase { class HivePressFavoritesAdapterTest extends TestCase {
@@ -32,72 +35,22 @@ class HivePressFavoritesAdapterTest extends TestCase {
public function test_constants_exposed(): void { public function test_constants_exposed(): void {
$this->assertSame( 'hp_favorite', WPDO_HivePress_Favorites_Adapter::COMMENT_TYPE ); $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(); $registry = WPDO_Custom_Table_Registry::instance();
$this->adapter->on_register_custom_tables( $registry ); $this->adapter->on_register_custom_tables( $registry );
$tables = $registry->all(); $this->assertSame( array(), $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'] );
} }
public function test_mirror_insert_skips_non_favorite_comments(): void { public function test_binds_no_comment_hooks(): void {
// Stub $wpdb tracks queries. $GLOBALS['_wp_filter_callbacks'] = array();
global $wpdb;
$wpdb->queries = array();
$comment = new stdClass(); $this->adapter->on_register_event_hooks();
$comment->comment_type = 'comment'; // ← not hp_favorite
$comment->user_id = 1;
$comment->comment_post_ID = 99;
$this->adapter->mirror_insert( 123, $comment ); $this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
$this->assertSame( array(), $wpdb->queries ); $this->assertArrayNotHasKey( 'deleted_comment', $GLOBALS['_wp_filter_callbacks'] );
}
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'] );
} }
public function test_score_aggregates_to_perfect_ten(): void { public function test_score_aggregates_to_perfect_ten(): void {
@@ -105,7 +58,7 @@ class HivePressFavoritesAdapterTest extends TestCase {
$this->assertSame( 10.0, $score['aggregate'] ); $this->assertSame( 10.0, $score['aggregate'] );
} }
public function test_migrations_lists_comment_module(): void { public function test_migrations_is_empty(): void {
$this->assertSame( array( 'comment_hp_favorite' ), $this->adapter->migrations() ); $this->assertSame( array(), $this->adapter->migrations() );
} }
} }
+146
View File
@@ -0,0 +1,146 @@
<?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',
);
protected function setUp(): void {
TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
TMDO_HivePress_Entity_Groups::register_entity_fields();
}
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" );
}
}
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() ); $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 { 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. // D8 = 0.9 → aggregate = (7×1.0 + 0.9)/8 × 10 = 9.875 → 9.88.
$this->assertSame( 9.88, $this->adapter->suitability_score()['aggregate'] ); $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() ); $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 { public function test_score_aggregates_to_perfect_ten(): void {
$score = $this->adapter->suitability_score(); $score = $this->adapter->suitability_score();
$this->assertSame( 10.0, $score['aggregate'] ); $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. * 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 * @covers WPDO_HivePress_Messages_Adapter
*/ */
class HivePressMessagesAdapterTest extends TestCase { class HivePressMessagesAdapterTest extends TestCase {
@@ -28,77 +31,26 @@ class HivePressMessagesAdapterTest extends TestCase {
$this->assertSame( 'hivepress-messages', $this->adapter->plugin_slug() ); $this->assertSame( 'hivepress-messages', $this->adapter->plugin_slug() );
$this->assertSame( 'HivePress\\Messages\\Plugin', $this->adapter->detection_class() ); $this->assertSame( 'HivePress\\Messages\\Plugin', $this->adapter->detection_class() );
$this->assertSame( 'hp_message', WPDO_HivePress_Messages_Adapter::COMMENT_TYPE ); $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(); $registry = WPDO_Custom_Table_Registry::instance();
$this->adapter->on_register_custom_tables( $registry ); $this->adapter->on_register_custom_tables( $registry );
$tables = $registry->all(); $this->assertSame( array(), $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'] );
} }
public function test_mirror_insert_skips_non_message_comments(): void { public function test_binds_no_comment_hooks(): void {
global $wpdb; $GLOBALS['_wp_filter_callbacks'] = array();
$wpdb->queries = array();
$comment = new stdClass(); $this->adapter->on_register_event_hooks();
$comment->comment_type = 'comment';
$comment->user_id = 1;
$comment->comment_karma = 5;
$this->adapter->mirror_insert( 1, $comment ); $this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
$this->assertSame( array(), $wpdb->queries ); $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 { public function test_migrations_is_empty(): void {
global $wpdb; $this->assertSame( array(), $this->adapter->migrations() );
$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'] );
} }
} }
+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 ); $this->assertSame( 'hp_offer', WPDO_HivePress_Requests_Adapter::OFFER_COMMENT_TYPE );
} }
public function test_on_register_fields_registers_request_hot_columns(): void { public function test_registers_no_offer_shadow_table(): void {
$registry = WPDO_Schema_Registry::instance(); // The offer shadow table was never created, so every mirror write failed.
$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 {
$registry = WPDO_Custom_Table_Registry::instance(); $registry = WPDO_Custom_Table_Registry::instance();
$this->adapter->on_register_custom_tables( $registry ); $this->adapter->on_register_custom_tables( $registry );
$tables = $registry->all(); $this->assertSame( array(), $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'] );
} }
public function test_mirror_offer_insert_skips_other_comment_types(): void { public function test_binds_no_comment_hooks(): void {
global $wpdb; $GLOBALS['_wp_filter_callbacks'] = array();
$wpdb->queries = array();
$comment = new stdClass(); $this->adapter->on_register_event_hooks();
$comment->comment_type = 'comment'; // ← not hp_offer
$comment->comment_post_ID = 99;
$comment->user_id = 1;
$this->adapter->mirror_offer_insert( 1, $comment ); $this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
$this->assertSame( array(), $wpdb->queries ); $this->assertArrayNotHasKey( 'edit_comment', $GLOBALS['_wp_filter_callbacks'] );
} $this->assertArrayNotHasKey( 'deleted_comment', $GLOBALS['_wp_filter_callbacks'] );
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] );
} }
public function test_score_aggregates_to_perfect_ten(): void { public function test_score_aggregates_to_perfect_ten(): void {
$this->assertSame( 10.0, $this->adapter->suitability_score()['aggregate'] ); $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 );
}
} }
+27 -11
View File
@@ -18,6 +18,8 @@ class HivePressSocialLinksAdapterTest extends TestCase {
$prop->setAccessible( true ); $prop->setAccessible( true );
$prop->setValue( null, null ); $prop->setValue( null, null );
unset( $GLOBALS['_wp_options']['hp_vendor_social_links'], $GLOBALS['_wp_options']['hp_listing_social_links'] );
$this->adapter = new WPDO_HivePress_Social_Links_Adapter(); $this->adapter = new WPDO_HivePress_Social_Links_Adapter();
} }
@@ -30,25 +32,39 @@ class HivePressSocialLinksAdapterTest extends TestCase {
$this->assertSame( 'HivePress\\SocialLinks\\Plugin', $this->adapter->detection_class() ); $this->assertSame( 'HivePress\\SocialLinks\\Plugin', $this->adapter->detection_class() );
} }
public function test_on_register_fields_registers_seven_cold_fields(): void { public function test_registers_enabled_links_per_model(): void {
// hivepress-social-links stores each enabled link as the attribute hp_{name},
// with separate admin selections for listings and vendors.
update_option( 'hp_vendor_social_links', array( 'facebook', 'website' ) );
update_option( 'hp_listing_social_links', array( 'instagram' ) );
$registry = WPDO_Schema_Registry::instance(); $registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry ); $this->adapter->on_register_fields( $registry );
// All seven social URL fields should land in cold zone on hp_vendor. $this->assertSame( 'cold', $registry->get_field( 'hp_vendor', 'hp_facebook' )['zone'] );
$facebook = $registry->get_field( 'hp_vendor', 'hp_facebook_url' ); $this->assertSame( 'cold', $registry->get_field( 'hp_vendor', 'hp_website' )['zone'] );
$this->assertSame( 'cold', $facebook['zone'] ); $this->assertSame( 'cold', $registry->get_field( 'hp_listing', 'hp_instagram' )['zone'] );
$instagram = $registry->get_field( 'hp_vendor', 'hp_instagram_url' ); $this->assertNull( $registry->get_field( 'hp_vendor', 'hp_instagram' ) );
$this->assertSame( 'cold', $instagram['zone'] ); $this->assertNull( $registry->get_field( 'hp_vendor', 'hp_facebook_url' ) );
$whatsapp = $registry->get_field( 'hp_vendor', 'hp_whatsapp_url' );
$this->assertSame( 'cold', $whatsapp['zone'] );
} }
public function test_doctor_check_reports_field_count(): void { public function test_registers_nothing_when_no_links_enabled(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
$this->assertNull( $registry->get_field( 'hp_vendor', 'hp_facebook' ) );
$this->assertNull( $registry->get_field( 'hp_listing', 'hp_facebook' ) );
}
public function test_doctor_check_reports_registered_count(): void {
update_option( 'hp_vendor_social_links', array( 'facebook', 'website' ) );
update_option( 'hp_listing_social_links', array( 'instagram' ) );
$result = $this->adapter->doctor_check(); $result = $this->adapter->doctor_check();
$this->assertTrue( $result['ok'] ); $this->assertTrue( $result['ok'] );
$this->assertStringContainsString( '7 cold fields', $result['message'] ); $this->assertStringContainsString( '3 cold fields', $result['message'] );
} }
public function test_score_aggregates_to_perfect_ten(): void { public function test_score_aggregates_to_perfect_ten(): void {
+1 -1
View File
@@ -7,7 +7,7 @@ declare(strict_types=1);
* Several existing wpdo tests (ZoneHotTest, SyncBridgeTest, etc.) replace the * Several existing wpdo tests (ZoneHotTest, SyncBridgeTest, etc.) replace the
* global $wpdb with their own anonymous-class mock and never restore it. When * 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 * 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. * cross-test pollution failures.
* *
* Trait reinstalls a known $wpdb mock + clears the wpdo_features option so * Trait reinstalls a known $wpdb mock + clears the wpdo_features option so