Compare commits
40 Commits
2540bee0d1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d6fd831416 | |||
| 763f705ac1 | |||
| 5953a5ed3b | |||
| 6909bf6a99 | |||
| 9803f8379d | |||
| f7f9bc5cc4 | |||
| bfe8912600 | |||
| 15bbf335f4 | |||
| 0cd2a9db5b | |||
| d802bf0145 | |||
| df62edd0f5 | |||
| 69cd4b6f8b | |||
| c139ebca89 | |||
| 0efac618de | |||
| e57dd5bd15 | |||
| b2585459e3 | |||
| d129139c7d | |||
| f06a80eae5 | |||
| 130678a8e6 | |||
| 788ddccfb3 | |||
| c6faaebbc1 | |||
| ef7e005583 | |||
| 99f4653b25 | |||
| bab91ef6b9 | |||
| b515b8441e | |||
| fda9e43087 | |||
| a01314cbb1 | |||
| 61762c49d0 | |||
| 0254e40d83 | |||
| 0e260ee3a9 | |||
| 69c8bf2a88 | |||
| dca134b5b6 | |||
| 3c57fb0ee6 | |||
| 7d0edb6228 | |||
| 19add5fc16 | |||
| ff6d6b10e4 | |||
| 5777210184 | |||
| f089b0934b | |||
| d3ddbb8933 | |||
| e443f20edb |
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: 2meet Data Optimizer — Hivepress AddOn
|
||||
* Plugin URI: https://2meet.io/2meet-data-optimizer/hivepress
|
||||
* Description: HivePress + 12 擴充整合:13 adapter (core/reviews/bookings/messages/memberships/requests/favorites/statistics/tags/seo/social-links/blocks/marketplace) + 7 HPCT interceptor + 5 query interceptor + admin tab + CLI namespace
|
||||
* Version: 0.1.0
|
||||
* Version: 0.5.3
|
||||
* Requires at least: 6.0
|
||||
* Tested up to: 6.9.4
|
||||
* Requires PHP: 8.1
|
||||
@@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define( 'TMDO_HIVEPRESS_VERSION', '0.1.0' );
|
||||
define( 'TMDO_HIVEPRESS_VERSION', '0.5.3' );
|
||||
define( 'TMDO_HIVEPRESS_PATH', plugin_dir_path( __FILE__ ) );
|
||||
define( 'TMDO_HIVEPRESS_URL', plugin_dir_url( __FILE__ ) );
|
||||
define( 'TMDO_HIVEPRESS_FILE', __FILE__ );
|
||||
|
||||
+154
@@ -9,6 +9,160 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [0.5.3] — 2026-10-07 — 瀏覽數 flush 不再造成漂移
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`TMDO_Listing_Stats::flush_views_to_postmeta()` 寫完後同步扁平表與快取**:每小時把 warm zone 的瀏覽數
|
||||
用原子 `UPDATE … meta_value + N` 加回 `hp_view_count`,繞過了 meta API:Hook Bus 看不到這次寫入,
|
||||
dual_write/shadow_read 下 `hp_listing_core.hp_view_count` 每批落後一次(夜間 drift-check 每晚 diff),
|
||||
物件快取(Redis 下跨請求)也一直是舊值。現在 UPDATE 之後清掉該篇的 `post_meta` 快取,
|
||||
並在原生仍是真相時(同時寫原生與扁平)用 `TMDO_Entity_Migration_Engine::migrate_entity()` 重建那一列。
|
||||
`hp_view_count` 同時在 `hp_listing_core` 與 `hp_request_core`:只重建含這個 key、而且適用該篇 post type 的群組
|
||||
(不帶 post type 的 `get_field()` 只回最後登記的那組)。aeav_only 不動(扁平才是真相)。
|
||||
|
||||
---
|
||||
|
||||
## [0.5.2] — 2026-10-05 — attribute bridge 的 meta key
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`TMDO_HivePress_Attribute_Bridge` 組出正確的 meta key**:HivePress 把每個模型的屬性存成
|
||||
`hp_{slug}`(例如 `hp_warranty`),不是 `hp_{model}_{slug}`。舊的 key 在資料庫裡不存在,
|
||||
照它建議的欄位登記後永遠是空的。
|
||||
|
||||
---
|
||||
|
||||
## [0.5.1] — 2026-10-02 — 重新打包
|
||||
|
||||
### Changed
|
||||
- 重新打包(無功能變更):與 WP-MVB v8 樞紐站(dev39)一同驗收後發版;程式碼與前一版相同。
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] — 2026-09-28
|
||||
|
||||
Released with 2meet-data-optimizer 1.4.0 (hardening 2.4).
|
||||
|
||||
### Changed
|
||||
|
||||
- `hp_listing_core` declares `(hp_price, post_id)`, `(hp_rating, post_id)` and
|
||||
`(hp_hourly_rate, post_id)` indexes for the listing sorts HivePress offers:
|
||||
with core 1.4.0 reading the ID tie-break from the flat `post_id`, a sort walks
|
||||
the index and stops at the page instead of sorting every match (dev36, 1.3M
|
||||
listings: price sort 14 s native → 0.37 s routed, hourly rate 11.5 s → 0.35 s,
|
||||
rating 43 s → 0.51 s). Core 1.3.0 ignores indexes ending with `post_id`, so
|
||||
nothing changes there. The pinned schema hash is unchanged. (PR-119)
|
||||
- Transient entries in a persistent object cache live a day (`MAX_CACHE_TTL`,
|
||||
HivePress's own default) instead of a week; a timeout still outlives its value
|
||||
by a minute. At a week dev36 kept 4.7M entries (1 GB) in the shared Redis and
|
||||
pushed other sites' keys out. Entries already cached keep their week. (PR-120)
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0] — 2026-09-27
|
||||
|
||||
Released with 2meet-data-optimizer 1.3.0 (hardening 2.3).
|
||||
|
||||
### Changed
|
||||
|
||||
- `hp_listing_core` declares indexes on `hp_hourly_rate` and `hp_rating_count`
|
||||
(HivePress filters and sorts on both). They are declared through the group's
|
||||
`indexes`, not `searchable`, so the pinned schema hash is unchanged and the
|
||||
table is not rebuilt; core 1.3.0 adds them in place from WP-CLI / WP-Cron.
|
||||
dev36 (902k listings): two indexes in about 4.7 s; routed `rating_count IN`
|
||||
2.1 s → 0.36 s, `rating_count !=` 2.1 s → 0.76 s, hourly rate + price sort
|
||||
2.1 s → 0.65 s. (PR-090)
|
||||
- With a persistent object cache, transient entries now expire: a value after
|
||||
a week (`MAX_CACHE_TTL`; HivePress writes it before its timeout, and a miss
|
||||
only makes it rebuild), its timeout a minute after the later of that and its
|
||||
own time — HivePress reads a missing timeout as "never expires", so the
|
||||
timeout must go last. Entries of deleted posts no longer pile up. (PR-099,
|
||||
PR-115)
|
||||
|
||||
### Added
|
||||
|
||||
- Opt-in flattening of a site's own listing attributes: the
|
||||
`tmdo_hp_listing_attribute_fields` filter (`'hp_warranty' => 'integer'`)
|
||||
registers them in an `hp_listing_attrs` group — a new table, each column
|
||||
indexed at creation — leaving `hp_listing_core` unchanged. Types: text,
|
||||
textarea, integer, decimal, boolean, date, datetime, timestamp, enum. The
|
||||
group is registered once themes load, so the filter may live in a theme;
|
||||
core 1.3.0 keeps it native until a drift fix has filled it. (PR-109, PR-115)
|
||||
|
||||
### Upgrading
|
||||
|
||||
- Deploy with core 1.3.0: on an older core the AddOn declares no index at all
|
||||
(core 1.2.0 would run the ALTER inside a web request). (PR-090b)
|
||||
- Redis: entries written by 0.3.0 have no TTL (dev36 had 3.0 M). Give them one
|
||||
with non-blocking commands — never `wp cache flush-group`, which the Redis
|
||||
Object Cache drop-in runs as one server-side script and which blocked Redis
|
||||
on a large group. `NX` leaves entries written by 0.4.0 alone:
|
||||
|
||||
```
|
||||
redis-cli --scan --pattern '<WP_CACHE_KEY_SALT>wp:tmdo_hp_transient:*' \
|
||||
| awk '{printf "EXPIRE %s 604800 NX\r\n", $0}' | redis-cli --pipe
|
||||
redis-cli --scan --pattern '<WP_CACHE_KEY_SALT>wp:tmdo_hp_transient:_transient_timeout_*' \
|
||||
| awk '{printf "EXPIRE %s 608400 GT\r\n", $0}' | redis-cli --pipe
|
||||
```
|
||||
|
||||
The second pass makes timeouts outlive their values. dev36: 3,013,352 keys
|
||||
in 45 s and 1,867,569 timeouts in 32 s, 0 errors, Redis latency max 2 ms. The pattern
|
||||
follows the drop-in's key layout (`<salt><blog prefix>:<group>:<key>`);
|
||||
check it with `redis-cli --scan --pattern '<salt>*tmdo_hp_transient*' | head`
|
||||
first. redis-cli 7 has no `--count` with `--scan`.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] — 2026-09-27
|
||||
|
||||
Released with 2meet-data-optimizer 1.2.0 (hardening 2.2).
|
||||
|
||||
### Changed
|
||||
|
||||
- With a persistent object cache (`wp_using_ext_object_cache()`), the
|
||||
transient filter keeps HivePress's per-post transient pairs in the
|
||||
`tmdo_hp_transient` cache group instead of `wp_options`, where they piled
|
||||
up (1.15 M pairs on dev36). The cache is then the only store: rows written
|
||||
before are neither read nor deleted (HivePress rebuilds those caches once),
|
||||
because a `get_option()` miss adds the name to WordPress's `notoptions` for
|
||||
good and a hit keeps the old row in the `options` cache group with no expiry.
|
||||
Without an object cache nothing changes. After upgrading, remove the rows
|
||||
left behind with core's `wp tmdo cleanup-hp-transients --options --confirm`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The detected / bound status cells of the HivePress admin table go through
|
||||
`esc_html()` (2.1 audit); output unchanged.
|
||||
|
||||
### Docs
|
||||
|
||||
- `docs/REGISTERED-KEYS.md`: the six registered keys HivePress never writes
|
||||
as meta, because its models alias them to core columns (listing
|
||||
`hp_vendor` → `post_parent`, `hp_status` → `post_status`; request
|
||||
`hp_status`, `hp_user`; vendor `hp_user`; review `hp_rating` →
|
||||
`comment_karma`). The schema stays; their flat columns are always NULL.
|
||||
- `docs/HIVEPRESS-COVERAGE.md`: dev36 inventory of the meta each HivePress
|
||||
post type holds, where the extensions keep their data and the query shapes
|
||||
the front end produces. Attribute auto-registration is not needed now:
|
||||
every meta key HivePress queries is registered.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] — 2026-09-26
|
||||
|
||||
Released with 2meet-data-optimizer 1.1.0 (hardening 2.1, search-index mode).
|
||||
|
||||
### Changed
|
||||
|
||||
- HivePress model query / search hooks (`optimize_query`, `optimize_search`)
|
||||
are no longer bound while core's post router is active
|
||||
(`TMDO_Post_Query_Router::is_router_active()`): core routes HivePress
|
||||
searches, including `_sort`, to the entity flat tables, and two routers
|
||||
would claim the same clauses.
|
||||
|
||||
### Added
|
||||
|
||||
- `tests/integration/`:bootstrap(複用核心的 integration bootstrap,連真實
|
||||
|
||||
@@ -43,6 +43,9 @@ reviews / messages / memberships / requests / listing-meta
|
||||
|
||||
---
|
||||
|
||||
Registered keys HivePress keeps in core columns instead of meta (`hp_vendor`, `hp_status`, …): see [docs/REGISTERED-KEYS.md](docs/REGISTERED-KEYS.md).
|
||||
What the HivePress extensions write and how they query it (dev36 inventory, D04 decision): see [docs/HIVEPRESS-COVERAGE.md](docs/HIVEPRESS-COVERAGE.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- WordPress ≥ 6.0
|
||||
|
||||
@@ -85,9 +85,9 @@ if ( ! class_exists( 'TMDO_Admin_HivePress' ) ) {
|
||||
<tr>
|
||||
<td><code><?php echo esc_html( $slug ); ?></code></td>
|
||||
<td><?php echo esc_html( $name ); ?></td>
|
||||
<td><?php echo $is_detected ? '✅' : '⚪'; ?></td>
|
||||
<td><?php echo esc_html( $is_detected ? '✅' : '⚪' ); ?></td>
|
||||
<td><?php echo esc_html( $detected[ $slug ] ?? '—' ); ?></td>
|
||||
<td><?php echo $is_bound ? '✅' : '—'; ?></td>
|
||||
<td><?php echo esc_html( $is_bound ? '✅' : '—' ); ?></td>
|
||||
<td><?php echo null === $score ? '—' : esc_html( (string) $score ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
@@ -138,7 +138,7 @@ wp wpdo hivepress benchmark</pre>
|
||||
. esc_html__( '偵測到衝突外掛:', 'tmdo-hivepress' ) . '</strong> '
|
||||
. esc_html( implode( ', ', $conflicts ) )
|
||||
. '</p><p>'
|
||||
. esc_html__( 'WPDO HivePress integration 已自動退讓。請執行 `wp wpdo hivepress migrate-from-hpct` 後停用舊外掛。', 'tmdo-hivepress' )
|
||||
. esc_html__( 'WPDO HivePress integration 已自動退讓。停用舊外掛後即由 WPDO 接手(舊外掛自有資料表的資料不會匯入)。', 'tmdo-hivepress' )
|
||||
. '</p></div>';
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -20,8 +20,6 @@ add_action(
|
||||
$aliases = array(
|
||||
'TMDO_Admin_HivePress' => 'WPDO_Admin_HivePress',
|
||||
'TMDO_CLI_HivePress' => 'WPDO_CLI_HivePress',
|
||||
'TMDO_Favorites_Interceptor' => 'WPDO_Favorites_Interceptor',
|
||||
'TMDO_HPCT_Import' => 'WPDO_HPCT_Import',
|
||||
'TMDO_HP_Bootstrap' => 'WPDO_HP_Bootstrap',
|
||||
'TMDO_HivePress' => 'WPDO_HivePress',
|
||||
'TMDO_HivePress_Attribute_Bridge' => 'WPDO_HivePress_Attribute_Bridge',
|
||||
@@ -29,9 +27,9 @@ add_action(
|
||||
'TMDO_HivePress_Blocks_Adapter' => 'WPDO_HivePress_Blocks_Adapter',
|
||||
'TMDO_HivePress_Bookings_Adapter' => 'WPDO_HivePress_Bookings_Adapter',
|
||||
'TMDO_HivePress_Bootstrap' => 'WPDO_HivePress_Bootstrap',
|
||||
'TMDO_HivePress_Comment_Router' => 'WPDO_HivePress_Comment_Router',
|
||||
'TMDO_HivePress_Conflict_Guard' => 'WPDO_HivePress_Conflict_Guard',
|
||||
'TMDO_HivePress_Core_Adapter' => 'WPDO_HivePress_Core_Adapter',
|
||||
'TMDO_HivePress_Entity_Groups' => 'WPDO_HivePress_Entity_Groups',
|
||||
'TMDO_HivePress_Cron_Optimizer' => 'WPDO_HivePress_Cron_Optimizer',
|
||||
'TMDO_HivePress_Detector' => 'WPDO_HivePress_Detector',
|
||||
'TMDO_HivePress_Favorites_Adapter' => 'WPDO_HivePress_Favorites_Adapter',
|
||||
@@ -48,18 +46,7 @@ add_action(
|
||||
'TMDO_HivePress_Tags_Adapter' => 'WPDO_HivePress_Tags_Adapter',
|
||||
'TMDO_Hivepress_Term_Comment_Fields' => 'WPDO_Hivepress_Term_Comment_Fields',
|
||||
'TMDO_Hivepress_Transient_Filter' => 'WPDO_Hivepress_Transient_Filter',
|
||||
'TMDO_Listing_Meta_Interceptor' => 'WPDO_Listing_Meta_Interceptor',
|
||||
'TMDO_Listing_Meta_Query' => 'WPDO_Listing_Meta_Query',
|
||||
'TMDO_Listing_Stats' => 'WPDO_Listing_Stats',
|
||||
'TMDO_Memberships_Interceptor' => 'WPDO_Memberships_Interceptor',
|
||||
'TMDO_Memberships_Query' => 'WPDO_Memberships_Query',
|
||||
'TMDO_Messages_Interceptor' => 'WPDO_Messages_Interceptor',
|
||||
'TMDO_Messages_Query' => 'WPDO_Messages_Query',
|
||||
'TMDO_Requests_Interceptor' => 'WPDO_Requests_Interceptor',
|
||||
'TMDO_Requests_Query' => 'WPDO_Requests_Query',
|
||||
'TMDO_Reviews_Interceptor' => 'WPDO_Reviews_Interceptor',
|
||||
'TMDO_Reviews_Query' => 'WPDO_Reviews_Query',
|
||||
'TMDO_Statistics_Interceptor' => 'WPDO_Statistics_Interceptor',
|
||||
);
|
||||
foreach ( $aliases as $tmdo_class => $wpdo_alias ) {
|
||||
if ( class_exists( $tmdo_class, false ) && ! class_exists( $wpdo_alias, false ) ) {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
/**
|
||||
* Bootstrap for 2meet Data Optimizer HivePress AddOn.
|
||||
*
|
||||
* 載入 13 adapter + 7 interceptor + 5 query interceptor + 補助組件 +
|
||||
* admin tab + CLI namespace。
|
||||
* 載入 13 adapter + 補助組件 + admin tab + CLI namespace。
|
||||
*
|
||||
* 注意:HP 的 V3.0.0 family bootstrap 內部也有一個 class TMDO_HivePress_Bootstrap
|
||||
* (在 includes/hivepress/class-tmdo-hivepress-bootstrap.php)。為避免衝突,本
|
||||
@@ -44,7 +43,6 @@ final class TMDO_HP_Bootstrap {
|
||||
require_once $base . 'includes/hivepress/class-tmdo-hivepress-detector.php';
|
||||
require_once $base . 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php';
|
||||
require_once $base . 'includes/hivepress/class-tmdo-hivepress-bootstrap.php';
|
||||
require_once $base . 'includes/hivepress/class-tmdo-hivepress-comment-router.php';
|
||||
require_once $base . 'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php';
|
||||
|
||||
// 13 adapters
|
||||
@@ -65,40 +63,11 @@ final class TMDO_HP_Bootstrap {
|
||||
require_once $base . 'includes/hivepress/class-tmdo-hivepress-benchmark.php';
|
||||
require_once $base . 'includes/hivepress/class-tmdo-hivepress-rest.php';
|
||||
|
||||
// 7 HPCT interceptors.
|
||||
// reviews / messages / memberships / requests extend the core's
|
||||
// TMDO_Standard_Post_Interceptor — requiring them against a core older than
|
||||
// v0.2.0 would fatal at parse time, so gate those four on the base class.
|
||||
$has_standard_base = class_exists( 'TMDO_Standard_Post_Interceptor' );
|
||||
foreach ( array(
|
||||
'reviews', 'messages', 'favorites', 'memberships',
|
||||
'statistics', 'requests', 'listing-meta',
|
||||
) as $intslug ) {
|
||||
$needs_base = in_array( $intslug, array( 'reviews', 'messages', 'memberships', 'requests' ), true );
|
||||
if ( $needs_base && ! $has_standard_base ) {
|
||||
continue;
|
||||
}
|
||||
$file = $base . "includes/interceptors/class-tmdo-{$intslug}-interceptor.php";
|
||||
if ( file_exists( $file ) ) {
|
||||
require_once $file;
|
||||
}
|
||||
}
|
||||
|
||||
// 5 query interceptors
|
||||
foreach ( array(
|
||||
'reviews', 'messages', 'memberships', 'requests', 'listing-meta',
|
||||
) as $qslug ) {
|
||||
$file = $base . "includes/query/class-tmdo-{$qslug}-query.php";
|
||||
if ( file_exists( $file ) ) {
|
||||
require_once $file;
|
||||
}
|
||||
}
|
||||
|
||||
// Misc HP integration files
|
||||
require_once $base . 'includes/class-tmdo-hivepress.php';
|
||||
require_once $base . 'includes/class-tmdo-hivepress-transient-filter.php';
|
||||
require_once $base . 'includes/class-tmdo-hpct-import.php';
|
||||
require_once $base . 'includes/class-tmdo-hivepress-term-comment-fields.php';
|
||||
require_once $base . 'includes/class-tmdo-hivepress-entity-groups.php';
|
||||
require_once $base . 'includes/class-tmdo-listing-stats.php';
|
||||
}
|
||||
|
||||
@@ -111,34 +80,6 @@ final class TMDO_HP_Bootstrap {
|
||||
TMDO_HivePress_Bootstrap::boot();
|
||||
}
|
||||
|
||||
// 7 HPCT interceptors
|
||||
foreach ( array(
|
||||
'TMDO_Reviews_Interceptor',
|
||||
'TMDO_Messages_Interceptor',
|
||||
'TMDO_Favorites_Interceptor',
|
||||
'TMDO_Memberships_Interceptor',
|
||||
'TMDO_Statistics_Interceptor',
|
||||
'TMDO_Requests_Interceptor',
|
||||
'TMDO_Listing_Meta_Interceptor',
|
||||
) as $cls ) {
|
||||
if ( class_exists( $cls ) ) {
|
||||
( new $cls() )->register_hooks();
|
||||
}
|
||||
}
|
||||
|
||||
// 5 query interceptors
|
||||
foreach ( array(
|
||||
'TMDO_Reviews_Query',
|
||||
'TMDO_Messages_Query',
|
||||
'TMDO_Memberships_Query',
|
||||
'TMDO_Requests_Query',
|
||||
'TMDO_Listing_Meta_Query',
|
||||
) as $cls ) {
|
||||
if ( class_exists( $cls ) ) {
|
||||
( new $cls() )->register_hooks();
|
||||
}
|
||||
}
|
||||
|
||||
// HP transient filter (init:5)
|
||||
if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
|
||||
add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 );
|
||||
@@ -156,6 +97,9 @@ final class TMDO_HP_Bootstrap {
|
||||
TMDO_Hivepress_Term_Comment_Fields::register();
|
||||
}
|
||||
|
||||
// Post + user entity groups (hp_listing_core / hp_request_core / hp_vendor_core / hp_user).
|
||||
TMDO_HivePress_Entity_Groups::register();
|
||||
|
||||
// Admin tab
|
||||
if ( is_admin() ) {
|
||||
$admin_file = TMDO_HIVEPRESS_PATH . 'admin/class-tmdo-admin-hivepress.php';
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
/**
|
||||
* HivePress entity groups owned by the HivePress AddOn.
|
||||
*
|
||||
* Groups → flat tables:
|
||||
* post:hp_listing_core → wp_wpdo_post_hp_listing_core (hp_listing)
|
||||
* post:hp_request_core → wp_wpdo_post_hp_request_core (hp_request)
|
||||
* post:hp_vendor_core → wp_wpdo_post_hp_vendor_core (hp_vendor)
|
||||
* user:hp_user → wp_wpdo_user_hp_user
|
||||
*
|
||||
* Fields only the Hot zone used to cover (ADR-003), as their own groups:
|
||||
* post:hp_draft_flags (hp_listing, hp_request)
|
||||
* post:hp_booking_core (hp_booking)
|
||||
* post:hp_membership_plan_core (hp_membership_plan)
|
||||
* post:hp_membership_core (hp_membership)
|
||||
* They register FIRST so the legacy last-registered owner of hp_expired_time
|
||||
* stays hp_request_core.
|
||||
*
|
||||
* The term/comment groups (hp_taxonomy, hp_review) live in
|
||||
* TMDO_Hivepress_Term_Comment_Fields.
|
||||
*
|
||||
* ⚠️ These definitions must stay byte-identical to the ones live sites
|
||||
* persisted in wpdo_registry_meta (originally registered by core's
|
||||
* TMDO_Post_Fields / TMDO_Member_Fields). Any drift changes the schema hash
|
||||
* and triggers ALTER TABLE on flat tables that may hold aeav_only data.
|
||||
* The three post groups share keys (hp_status, hp_user, …): each declares
|
||||
* its post type so a post's key resolves to its own type's group, while
|
||||
* lookups without a post type keep last-registered-wins — keep their order.
|
||||
*
|
||||
* @package TMDO_HIVEPRESS
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the HivePress post + user entity groups.
|
||||
*/
|
||||
final class TMDO_HivePress_Entity_Groups {
|
||||
|
||||
/**
|
||||
* Hook into WPDO entity field registration and the REST default post type.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
|
||||
add_filter( 'tmdo_rest_default_post_type', array( __CLASS__, 'rest_default_post_type' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /wpdo/v1/listings serves HivePress listings when no post type is given.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function rest_default_post_type(): string {
|
||||
return 'hp_listing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the groups (order matters, see file header).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_entity_fields(): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::register_hp_draft_flags_group();
|
||||
self::register_hp_booking_core_group();
|
||||
self::register_hp_membership_plan_core_group();
|
||||
self::register_hp_membership_core_group();
|
||||
self::register_hp_listing_core_group();
|
||||
self::register_hp_request_core_group();
|
||||
self::register_hp_vendor_core_group();
|
||||
self::register_hp_user_group();
|
||||
self::register_hp_listing_attributes_group();
|
||||
}
|
||||
|
||||
// ── Group definitions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Draft flag shared by listings and requests (user-dashboard filtering).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_draft_flags_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_draft_flags',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_drafted',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Drafted flag (0/1)',
|
||||
),
|
||||
),
|
||||
array( 'post_types' => array( 'hp_listing', 'hp_request' ) )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress Bookings (post_type=hp_booking): slot-availability lookups.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_booking_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_booking_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_start_time',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Booking start unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_end_time',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Booking end unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_quantity',
|
||||
'type' => 'integer',
|
||||
'label' => 'Places booked',
|
||||
),
|
||||
),
|
||||
array( 'post_types' => array( 'hp_booking' ) )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress Memberships plan (post_type=hp_membership_plan).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_membership_plan_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_membership_plan_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_expire_period',
|
||||
'type' => 'integer',
|
||||
'label' => 'Plan expiry period (days)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_primary',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Primary plan flag (0/1)',
|
||||
),
|
||||
),
|
||||
array( 'post_types' => array( 'hp_membership_plan' ) )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress Memberships subscription (post_type=hp_membership).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_membership_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_membership_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_expired_time',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Expiry unix ts',
|
||||
),
|
||||
),
|
||||
array( 'post_types' => array( 'hp_membership' ) )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress listing core meta keys (post_type=hp_listing).
|
||||
* Aligns with the existing wpdo_hot_hp_listing flat table; v2.9.5 will
|
||||
* copy-then-cutover the 230 dev10 rows to this group's table.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_listing_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_listing_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Listing price',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_status',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'publish', 'draft', 'pending', 'expired', 'private' ),
|
||||
'label' => 'Listing status',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_featured',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Featured flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_verified',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Verified flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_vendor',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Vendor user ID',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_expired_time',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Expiry unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_featured_time',
|
||||
'type' => 'integer',
|
||||
'label' => 'Featured-until unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_view_count',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'View counter',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Average rating',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'Rating count',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_hourly_rate',
|
||||
'type' => 'decimal',
|
||||
'label' => 'Hourly rate',
|
||||
),
|
||||
),
|
||||
array(
|
||||
'post_types' => array( 'hp_listing' ),
|
||||
// Filtered and sorted on by HivePress search. Declared as indexes
|
||||
// (added in place) rather than `searchable`, which would change
|
||||
// the schema hash and rebuild a table that may hold aeav_only data.
|
||||
// Only with core 1.3.0+ (ddl_allowed()): older cores run the ALTER
|
||||
// inside whatever request comes first, a visitor's included.
|
||||
'indexes' => method_exists( 'TMDO_Schema_Manager', 'ddl_allowed' ) ? array(
|
||||
'hourly_rate' => array( 'hp_hourly_rate' ),
|
||||
'rating_count' => array( 'hp_rating_count' ),
|
||||
// HivePress sorts: the value then the ID tie-break, which core 1.4.0
|
||||
// reads in order (flat-first) instead of sorting every match.
|
||||
'price_id' => array( 'hp_price', 'post_id' ),
|
||||
'rating_id' => array( 'hp_rating', 'post_id' ),
|
||||
'hourly_rate_id' => array( 'hp_hourly_rate', 'post_id' ),
|
||||
) : array(),
|
||||
)
|
||||
);
|
||||
}
|
||||
/**
|
||||
* A site's own HivePress listing attributes (created under Listings →
|
||||
* Attributes, e.g. a "Warranty" number → hp_warranty), opt-in through the
|
||||
* `tmdo_hp_listing_attribute_fields` filter: `array( 'hp_warranty' => 'integer' )`.
|
||||
*
|
||||
* They get a group of their own — a new table, each column indexed at
|
||||
* creation — so hp_listing_core and its pinned definition stay unchanged.
|
||||
* Keys another group already manages, keys without the hp_ prefix and
|
||||
* types that cannot take a plain index (json, binary) are ignored. Core
|
||||
* fills the new table from native meta (drift fix) before using it.
|
||||
*
|
||||
* Registered once themes are loaded (the init:1 run): a filter added in a
|
||||
* theme is seen, and the group gets one definition per request.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_listing_attributes_group(): void {
|
||||
if ( ! did_action( 'after_setup_theme' ) ) {
|
||||
return;
|
||||
}
|
||||
$fields = array();
|
||||
foreach ( (array) apply_filters( 'tmdo_hp_listing_attribute_fields', array() ) as $key => $type ) {
|
||||
$key = (string) $key;
|
||||
$owner = TMDO_Entity_Registry::get_field( 'post', $key, 'hp_listing' );
|
||||
if ( 1 !== preg_match( '/^hp_[a-z0-9_]+$/', $key )
|
||||
|| ! in_array( $type, array( 'text', 'textarea', 'integer', 'decimal', 'boolean', 'date', 'datetime', 'timestamp', 'enum' ), true )
|
||||
|| ( null !== $owner && 'hp_listing_attrs' !== $owner['group'] ) ) {
|
||||
continue;
|
||||
}
|
||||
$fields[] = array(
|
||||
'key' => $key,
|
||||
'type' => $type,
|
||||
'searchable' => true,
|
||||
'label' => 'Site listing attribute',
|
||||
);
|
||||
}
|
||||
if ( $fields ) {
|
||||
TMDO_Entity_Registry::register_group( 'post', 'hp_listing_attrs', $fields, array( 'post_types' => array( 'hp_listing' ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress request core meta keys (post_type=hp_request).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_request_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_request_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_status',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'publish', 'draft', 'pending', 'expired' ),
|
||||
'label' => 'Request status',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_user',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Request author user ID',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_expired_time',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Expiry unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_budget',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Budget amount',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_view_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'View counter',
|
||||
),
|
||||
),
|
||||
array( 'post_types' => array( 'hp_request' ) )
|
||||
);
|
||||
}
|
||||
/**
|
||||
* HivePress vendor core meta keys (post_type=hp_vendor).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_vendor_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_vendor_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_user',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Vendor user ID (linked WP user)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_verified',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Verified flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_hourly_rate',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Hourly rate',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'Rating count',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Average rating',
|
||||
),
|
||||
),
|
||||
array( 'post_types' => array( 'hp_vendor' ) )
|
||||
);
|
||||
}
|
||||
/**
|
||||
* HivePress per-user fields (favorites + avatar attachment).
|
||||
*
|
||||
* Hp_favorited_listings is a serialized array of post IDs in legacy storage;
|
||||
* the migration engine safe_unserialize()s it (allowed_classes=false to block
|
||||
* PHP-object injection) then the json type encodes back to a JSON array column.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_user_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'hp_user',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_favorited_listings',
|
||||
'type' => 'json',
|
||||
'label' => 'HivePress favorited listing IDs (array)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_image',
|
||||
'type' => 'text',
|
||||
'label' => 'HivePress avatar attachment ID',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,12 @@ final class TMDO_Hivepress_Transient_Filter {
|
||||
/** Namespace prefix for translated wp_options entries. */
|
||||
public const TRANSLATED_NAMESPACE = 'wpdo_hp_pm_';
|
||||
|
||||
/** Object cache group used instead of wp_options when a persistent cache is present. */
|
||||
public const CACHE_GROUP = 'tmdo_hp_transient';
|
||||
|
||||
/** Longest an object-cache entry lives: a day, HivePress's own default (2.4 PR-120). */
|
||||
public const MAX_CACHE_TTL = DAY_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Register the four metadata filters on `init`. Idempotent — safe to call
|
||||
* multiple times.
|
||||
@@ -164,7 +170,16 @@ final class TMDO_Hivepress_Transient_Filter {
|
||||
// HivePress cache layer reads the timeout *first*, then decides whether
|
||||
// to read the value. We must preserve raw stored values until HivePress
|
||||
// itself orders deletion.
|
||||
if ( wp_using_ext_object_cache() ) {
|
||||
// The object cache is the only store. Rows left in wp_options from
|
||||
// before are not read (`wp tmdo cleanup-hp-transients --options`
|
||||
// removes them): a get_option() miss would add the name to
|
||||
// `notoptions` for good, as later writes never reach wp_options.
|
||||
$value = wp_cache_get( $option_name, self::CACHE_GROUP );
|
||||
$value = false === $value ? null : $value;
|
||||
} else {
|
||||
$value = get_option( $option_name, null );
|
||||
}
|
||||
|
||||
if ( null === $value ) {
|
||||
// Cache miss in our store → fall through to normal postmeta path
|
||||
@@ -239,16 +254,27 @@ final class TMDO_Hivepress_Transient_Filter {
|
||||
$translated = self::translate_key( (int) $object_id, (string) $meta_key );
|
||||
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
|
||||
|
||||
if ( wp_using_ext_object_cache() ) {
|
||||
wp_cache_delete( $option_name, self::CACHE_GROUP );
|
||||
return true;
|
||||
}
|
||||
delete_option( $option_name );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: persist value or timeout to wp_options.
|
||||
* Internal: persist value or timeout to the object cache when a persistent
|
||||
* one is in use (Redis: a TTL cache belongs there, not in wp_options), else
|
||||
* to wp_options.
|
||||
*
|
||||
* Both halves of the transient pair are written with `autoload=no` (they
|
||||
* are read on-demand only — never via wp_load_alloptions() — so eagerly
|
||||
* loading them on every page would defeat the point of the move).
|
||||
* In wp_options both halves of the transient pair are written with
|
||||
* `autoload=no` (they are read on-demand only — never via
|
||||
* wp_load_alloptions() — so eagerly loading them on every page would defeat
|
||||
* the point of the move). In the object cache the value, written before its
|
||||
* timeout, expires after MAX_CACHE_TTL (a miss only makes HivePress rebuild
|
||||
* it) and the timeout a minute after the later of that and its own time,
|
||||
* so HivePress always sees it expire first; entries of deleted posts do
|
||||
* not pile up (2.3 PR-099).
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $meta_key Original `_transient_*` meta_key.
|
||||
@@ -260,6 +286,13 @@ final class TMDO_Hivepress_Transient_Filter {
|
||||
$translated = self::translate_key( $post_id, $meta_key );
|
||||
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
|
||||
|
||||
if ( wp_using_ext_object_cache() ) {
|
||||
// A timeout outlives its value: HivePress takes a missing timeout for
|
||||
// "never expires" and would keep serving the value.
|
||||
$ttl = $is_timeout ? max( self::MAX_CACHE_TTL, (int) $meta_value - time() ) + MINUTE_IN_SECONDS : self::MAX_CACHE_TTL;
|
||||
wp_cache_set( $option_name, $meta_value, self::CACHE_GROUP, $ttl );
|
||||
return;
|
||||
}
|
||||
update_option( $option_name, $meta_value, false );
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,8 @@ class TMDO_Listing_Stats {
|
||||
// Seed the key when it does not exist yet.
|
||||
if ( ! $updated ) {
|
||||
add_post_meta( $post_id, 'hp_view_count', $new_views, true );
|
||||
} else {
|
||||
self::resync_view_count( $post_id );
|
||||
}
|
||||
|
||||
// Remove the warm entry after flush.
|
||||
@@ -164,6 +166,40 @@ class TMDO_Listing_Stats {
|
||||
return $flushed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The atomic UPDATE above bypasses the meta API: the object cache (persistent
|
||||
* under Redis) keeps the old count, and the Hook Bus never sees the write, so in
|
||||
* dual_write / shadow_read the flat column drifts by every flushed batch
|
||||
* (drift-check diff on hp_view_count each night). Drop the cached meta and
|
||||
* rebuild the entity's flat row from native — only while native is the source
|
||||
* of truth (aeav_only keeps flat authoritative).
|
||||
*
|
||||
* @param int $post_id Listing post ID.
|
||||
*/
|
||||
private static function resync_view_count( int $post_id ): void {
|
||||
wp_cache_delete( $post_id, 'post_meta' );
|
||||
if ( ! class_exists( 'TMDO_Mode_Manager' ) || ! class_exists( 'TMDO_Entity_Registry' ) || ! class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! TMDO_Mode_Manager::writes_to_flat( 'post' ) || ! TMDO_Mode_Manager::writes_to_eav( 'post' ) ) {
|
||||
return;
|
||||
}
|
||||
// hp_view_count lives in more than one group (hp_listing_core, hp_request_core):
|
||||
// get_field() without a post type returns whichever registered last. Rebuild only
|
||||
// the groups that hold the key and apply to this post's type.
|
||||
$type = (string) get_post_type( $post_id );
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||||
$types = TMDO_Entity_Registry::get_group_post_types( 'post', $group );
|
||||
if ( ! in_array( 'hp_view_count', TMDO_Entity_Registry::get_group_keys( 'post', $group ), true ) || ( $types && ! in_array( $type, $types, true ) ) ) {
|
||||
continue;
|
||||
}
|
||||
TMDO_Entity_Migration_Engine::migrate_entity( 'post', $group, $post_id );
|
||||
if ( class_exists( 'TMDO_Cache_Orchestrator' ) ) {
|
||||
TMDO_Cache_Orchestrator::invalidate( 'post', $post_id, $group );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Zone D: Expired Listing Archival ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,17 +8,13 @@
|
||||
* Booking model (post_type=hp_booking) — fields are best-effort from the
|
||||
* addon's documented field aliases:
|
||||
*
|
||||
* start_time / end_time → unix timestamp range queries (hot)
|
||||
* status → enum filter (hot)
|
||||
* guests → numeric count (hot for slot-availability)
|
||||
* start_time / end_time → unix timestamp range queries
|
||||
* quantity → places booked, hp_quantity (only written when
|
||||
* "Allow multiple places" is on)
|
||||
* listing → post_parent (already a posts column)
|
||||
*
|
||||
* Hot-zone target: `wp_wpdo_hot_hp_booking` with covering indexes on
|
||||
* `(listing_id, start_time)` for slot-availability queries and
|
||||
* `(status, start_time)` for vendor dashboard listings.
|
||||
*
|
||||
* The schema MAY drift from real Booking model when the addon is finally
|
||||
* installed; reconcile with `wp wpdo doctor` then.
|
||||
* These keys are flattened by the `hp_booking_core` entity group
|
||||
* (TMDO_HivePress_Entity_Groups); the adapter registers no zone fields.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
@@ -64,73 +60,6 @@ if ( ! class_exists( 'TMDO_HivePress_Bookings_Adapter' ) ) {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hot fields for hp_booking post type.
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry Schema registry singleton.
|
||||
*/
|
||||
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
|
||||
$registry->register_many(
|
||||
$this->plugin_slug(),
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'hp_booking',
|
||||
'meta_key' => 'hp_start_time',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_start_time',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_booking',
|
||||
'meta_key' => 'hp_end_time',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_end_time',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_booking',
|
||||
'meta_key' => 'hp_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.
|
||||
*
|
||||
@@ -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 )
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
/**
|
||||
* Core HivePress adapter — `hivepress` (the parent plugin).
|
||||
*
|
||||
* Owns three primary HivePress models:
|
||||
* Covers the primary HivePress models:
|
||||
*
|
||||
* - `Listing` (post_type=hp_listing) — Hot zone columns for search/filter
|
||||
* (drafted, featured, verified, expired_time, featured_time).
|
||||
* - `Vendor` (post_type=hp_vendor) — Hot column `verified`; cold blob
|
||||
* for image / description (display-only).
|
||||
* - `User` (entity=user) — Entity group `hp_user_core` for
|
||||
* `hp_verified` and the WP-stock `first_name` / `last_name` /
|
||||
* `description`.
|
||||
* - `Listing` (post_type=hp_listing) — cold blob for the gallery.
|
||||
* - `Vendor` (post_type=hp_vendor) — cold blob for the image.
|
||||
* - `User` (entity=user) — no zone fields; its `hp_user`
|
||||
* entity group is registered by TMDO_HivePress_Entity_Groups.
|
||||
*
|
||||
* Search/filter keys are flattened by TMDO_HivePress_Entity_Groups
|
||||
* (ADR-003: no Hot zone fields).
|
||||
*
|
||||
* This adapter SUPERSEDES the legacy `class-tmdo-hivepress.php` (242 lines).
|
||||
* Behavioural parity:
|
||||
@@ -24,14 +24,9 @@
|
||||
* adapter only registers `hivepress` core fields.
|
||||
* - Vendor cold fields (`hp_image`, `hp_images`) — preserved.
|
||||
*
|
||||
* NEW additions vs legacy:
|
||||
*
|
||||
* - `hp_drafted` (Listing) — Hot tinyint(1) for user-dashboard filtering.
|
||||
* - `hp_expired_time` / `hp_featured_time` (Listing) — Hot bigint indexed
|
||||
* for cron expiry scan acceleration (replaces postmeta full scan).
|
||||
* - `hp_user_core` entity group — first_name / last_name / hp_verified
|
||||
* promoted from usermeta to wp_wpdo_user_hp_user (already created by
|
||||
* TMDO_Member_Fields v2.7.0).
|
||||
* (This adapter used to register a second `hp_user` definition —
|
||||
* hp_verified / hp_avatar_id, keys HivePress never writes — which the
|
||||
* registry silently dropped; TMDO_HivePress_Entity_Groups owns it now.)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
@@ -115,37 +110,10 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
|
||||
* @param TMDO_Schema_Registry $registry Schema registry singleton.
|
||||
*/
|
||||
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
|
||||
// ── Listing hot fields (search/filter accelerators) ──
|
||||
// ── Cold blobs (display-only) ──
|
||||
$registry->register_many(
|
||||
$this->plugin_slug(),
|
||||
array(
|
||||
// Drafted — used in user dashboard "my drafts" filter.
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_drafted',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_drafted',
|
||||
'indexed' => true,
|
||||
),
|
||||
// Expired time — replaces `meta_query` clause in hourly cron.
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_expired_time',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_expired_time',
|
||||
'indexed' => true,
|
||||
),
|
||||
// Featured time — same expiry pattern as above.
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_featured_time',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_featured_time',
|
||||
'indexed' => true,
|
||||
),
|
||||
// Vendor cold blob — image attachment id (display-only).
|
||||
array(
|
||||
'post_type' => 'hp_vendor',
|
||||
@@ -166,53 +134,18 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register entity field group for HP User model.
|
||||
*
|
||||
* The `hp_user` group is already created by `TMDO_Member_Fields`
|
||||
* (v2.7.0). We add adapter ownership here for cross-cutting tooling
|
||||
* (`wp wpdo hivepress doctor` reports owner; admin UI shows source).
|
||||
*
|
||||
* The `register_entity_fields()` helper from `TMDO_Anti_EAV_Aware`
|
||||
* delegates to `TMDO_Entity_Registry::register_group()` which has
|
||||
* internal dedup — so re-registering with same group name is a no-op.
|
||||
*/
|
||||
public function on_register_entity_fields(): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mirror the (canonical) group definition from TMDO_Member_Fields::HP_USER_FIELDS.
|
||||
// Source of truth lives in TMDO_Member_Fields; we register defensively for the
|
||||
// case where Member_Fields didn't load (custom builds without that integration).
|
||||
$this->register_entity_fields(
|
||||
'user',
|
||||
'hp_user',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_verified',
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'searchable' => true,
|
||||
'label' => 'HivePress KYC verified',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_avatar_id',
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'searchable' => false,
|
||||
'label' => 'HivePress avatar attachment id',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Query optimization (preserved from legacy class-tmdo-hivepress.php) ──
|
||||
|
||||
/**
|
||||
* Bind HivePress search query hooks at priority 20 (after HP setup).
|
||||
*
|
||||
* Not while core's post router serves HivePress searches from the
|
||||
* entity flat tables: two routers would claim the same clauses.
|
||||
*/
|
||||
public function on_register_query_hooks(): void {
|
||||
if ( class_exists( 'TMDO_Post_Query_Router' ) && TMDO_Post_Query_Router::is_router_active() ) {
|
||||
return;
|
||||
}
|
||||
foreach ( self::HOT_MODELS as $model => $post_type ) {
|
||||
unset( $post_type ); // Bound below by string-interpolated $model only.
|
||||
add_action(
|
||||
@@ -318,57 +251,6 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
|
||||
|
||||
// ── Doctor / score / migrations ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Health probe: verify hot tables exist + sample row count.
|
||||
*
|
||||
* @return array{ok:bool, message:string, details?:array<string,mixed>}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
global $wpdb;
|
||||
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => 'wpdb global not available',
|
||||
);
|
||||
}
|
||||
|
||||
$details = array();
|
||||
$ok = true;
|
||||
foreach ( self::HOT_MODELS as $post_type ) {
|
||||
$table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type );
|
||||
try {
|
||||
$exists = (bool) $wpdb->get_var(
|
||||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
||||
);
|
||||
$details[ $post_type ] = array(
|
||||
'table' => $table,
|
||||
'exists' => $exists,
|
||||
);
|
||||
if ( $exists ) {
|
||||
$details[ $post_type ]['rows'] = (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$table}`" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Sanitized via sanitize_key + wpdb prefix.
|
||||
);
|
||||
} else {
|
||||
$ok = false;
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
$details[ $post_type ] = array(
|
||||
'table' => $table,
|
||||
'error' => $e->getMessage(),
|
||||
);
|
||||
$ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'ok' => $ok,
|
||||
'message' => $ok
|
||||
? 'hivepress core: hot tables OK'
|
||||
: 'hivepress core: one or more hot tables missing or unreadable',
|
||||
'details' => $details,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-dimension self-score for the core adapter.
|
||||
*
|
||||
@@ -390,18 +272,6 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FSM modules this adapter contributes.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function migrations(): array {
|
||||
return array(
|
||||
'hot_hp_listing',
|
||||
'hot_hp_vendor',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
|
||||
@@ -8,27 +8,9 @@
|
||||
* - listing → comment_post_ID
|
||||
* - added_at → comment_date
|
||||
*
|
||||
* Problem: `wp_comments` has no UNIQUE constraint on (user_id, comment_post_ID, comment_type).
|
||||
* A double-tap on the favourite button can race and create two duplicate
|
||||
* favourite rows. HivePress relies on application-level dedup (SELECT before
|
||||
* INSERT) which is not race-safe.
|
||||
*
|
||||
* Fix: shadow table `wp_wpdo_comment_hp_favorite` with UNIQUE(user_id, listing_id).
|
||||
* Adapter mirrors every favorite write into the shadow table; the comment
|
||||
* router rewrites "is X favorited by user Y" reads to hit the shadow table
|
||||
* (1 indexed lookup vs WP_Comment_Query meta scan).
|
||||
*
|
||||
* Schema (declared via expected_columns; created by TMDO_Schema_Manager
|
||||
* when adapter mode flips to dual_write):
|
||||
*
|
||||
* CREATE TABLE wp_wpdo_comment_hp_favorite (
|
||||
* comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
* user_id BIGINT UNSIGNED NOT NULL,
|
||||
* listing_id BIGINT UNSIGNED NOT NULL,
|
||||
* created_at DATETIME NOT NULL,
|
||||
* UNIQUE KEY unique_user_listing (user_id, listing_id),
|
||||
* KEY idx_listing (listing_id)
|
||||
* );
|
||||
* Nothing is stored in comment meta, so there is no EAV surface to flatten:
|
||||
* the adapter only reports detection and doctor status. (It used to mirror
|
||||
* favorites into `wpdo_comment_hp_favorite`, a table nothing ever created.)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
@@ -41,18 +23,15 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Favorites addon adapter.
|
||||
* Favorites addon adapter (detection and doctor only).
|
||||
*/
|
||||
final class TMDO_HivePress_Favorites_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/** Comment_type owned by this adapter. */
|
||||
/** Comment_type of the Favorite model. */
|
||||
public const COMMENT_TYPE = 'hp_favorite';
|
||||
|
||||
/** Shadow table name (without wpdb prefix). */
|
||||
public const TABLE = 'wpdo_comment_hp_favorite';
|
||||
|
||||
/**
|
||||
* Constructor — bind register hooks via inherited trait.
|
||||
*/
|
||||
@@ -79,156 +58,6 @@ if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) {
|
||||
public function minimum_addon_version(): string {
|
||||
return '1.2.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the shadow table to the Custom_Table_Registry.
|
||||
*
|
||||
* @param TMDO_Custom_Table_Registry $registry Registry singleton.
|
||||
*/
|
||||
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void {
|
||||
$this->register_custom_table(
|
||||
$registry,
|
||||
array(
|
||||
'table_name' => self::TABLE,
|
||||
'primary_key' => 'comment_id',
|
||||
'post_type_link' => null,
|
||||
'expected_columns' => array(
|
||||
'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY',
|
||||
'user_id' => 'BIGINT UNSIGNED NOT NULL',
|
||||
'listing_id' => 'BIGINT UNSIGNED NOT NULL',
|
||||
'created_at' => 'DATETIME NOT NULL',
|
||||
),
|
||||
'indexes' => array(
|
||||
'unique_user_listing' => 'UNIQUE (user_id, listing_id)',
|
||||
'idx_listing' => '(listing_id)',
|
||||
),
|
||||
'doctor_callback' => array( $this, 'doctor_check' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync wp_comments → shadow table on insert.
|
||||
*
|
||||
* Bound during `on_register_event_hooks()`. The comment-router uses
|
||||
* the shadow table for "is favorited" lookups; sync keeps it consistent.
|
||||
*/
|
||||
public function on_register_event_hooks(): void {
|
||||
add_action( 'wp_insert_comment', array( $this, 'mirror_insert' ), 10, 2 );
|
||||
add_action( 'deleted_comment', array( $this, 'mirror_delete' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a newly-inserted hp_favorite into the shadow table.
|
||||
*
|
||||
* @param int $comment_id Comment ID.
|
||||
* @param object|\WP_Comment $comment Comment object.
|
||||
*/
|
||||
public function mirror_insert( int $comment_id, $comment ): void {
|
||||
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$user_id = (int) ( $comment->user_id ?? 0 );
|
||||
$listing_id = (int) ( $comment->comment_post_ID ?? 0 );
|
||||
if ( $user_id <= 0 || $listing_id <= 0 ) {
|
||||
return;
|
||||
}
|
||||
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare(
|
||||
'INSERT IGNORE INTO `' . $wpdb->prefix . self::TABLE . '` (comment_id, user_id, listing_id, created_at) VALUES (%d, %d, %d, %s)',
|
||||
$comment_id,
|
||||
$user_id,
|
||||
$listing_id,
|
||||
$comment->comment_date ?? current_time( 'mysql' )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the shadow row when a hp_favorite comment is deleted.
|
||||
*
|
||||
* @param int $comment_id Comment ID.
|
||||
* @param object|\WP_Comment $comment Comment object.
|
||||
*/
|
||||
public function mirror_delete( int $comment_id, $comment ): void {
|
||||
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prefix . self::TABLE,
|
||||
array( 'comment_id' => $comment_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — verify shadow table exists.
|
||||
*
|
||||
* @return array{ok:bool, message:string, details?:array<string,mixed>}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
global $wpdb;
|
||||
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => 'wpdb global not available',
|
||||
);
|
||||
}
|
||||
$table = $wpdb->prefix . self::TABLE;
|
||||
try {
|
||||
$exists = (bool) $wpdb->get_var(
|
||||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
||||
);
|
||||
if ( ! $exists ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => sprintf( 'hivepress-favorites: shadow table %s missing', $table ),
|
||||
'details' => array( 'table' => $table ),
|
||||
);
|
||||
}
|
||||
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => sprintf( 'hivepress-favorites: %s rows=%d', $table, $rows ),
|
||||
'details' => array(
|
||||
'table' => $table,
|
||||
'rows' => $rows,
|
||||
),
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => sprintf( 'hivepress-favorites doctor failed: %s', $e->getMessage() ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score.
|
||||
*
|
||||
* D1: 1.0 — adapter never calls *_meta()
|
||||
* D2: 1.0 — uses wpdb prefix, no hardcoded table literals
|
||||
* D3: 1.0 — shadow table covers the model
|
||||
* D4: 1.0 — expected_columns + indexes registered
|
||||
* D5: 1.0 — listens to wp_insert_comment for cross-write consistency
|
||||
* D6: 1.0 — zero options/transients
|
||||
* D7: 1.0 — only queries own shadow table
|
||||
* D8: 1.0 — UNIQUE(user_id, listing_id) covering index
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* FSM modules this adapter contributes.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function migrations(): array {
|
||||
return array( 'comment_hp_favorite' );
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
|
||||
@@ -3,16 +3,14 @@
|
||||
* Marketplace adapter — `hivepress-marketplace` (DETECT-ONLY in v3.0.0).
|
||||
*
|
||||
* The marketplace addon turns listings into purchasable items, integrating
|
||||
* with WooCommerce. It adds:
|
||||
* with WooCommerce. Its filterable, sortable listing attribute `price` is
|
||||
* stored as `hp_price` → decimal(10,2), used in price-range filters. It is
|
||||
* flattened by the `hp_listing_core` entity group
|
||||
* (TMDO_HivePress_Entity_Groups); the adapter registers no zone fields.
|
||||
*
|
||||
* hp_purchase_price → decimal(10,2), used in price-range filters (HOT)
|
||||
* hp_purchase_count → counter (WARM zone, low-criticality)
|
||||
*
|
||||
* Note: `hp_purchase_price` is also conditionally registered by the existing
|
||||
* legacy hivepress integration (Sprint 1 left that path) — the dedup in
|
||||
* Schema_Registry is provider+key based, so registering again under the
|
||||
* `hivepress-marketplace` provider is safe and lets `wp wpdo doctor` show
|
||||
* the right ownership.
|
||||
* Its other listing attributes (price tiers / extras / discounts, quantity,
|
||||
* subscription period, …) are not search filters, and it keeps no per-listing
|
||||
* sales counter.
|
||||
*
|
||||
* Detect-only: not installed in dev environment. Schema reserved for the
|
||||
* day the addon ships.
|
||||
@@ -61,63 +59,6 @@ if ( ! class_exists( 'TMDO_HivePress_Marketplace_Adapter' ) ) {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register marketplace hot fields on the listing post type.
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry Schema registry singleton.
|
||||
*/
|
||||
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
|
||||
$registry->register_many(
|
||||
$this->plugin_slug(),
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_purchase_price',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'decimal(10,2) DEFAULT NULL',
|
||||
'column' => 'hp_purchase_price',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_purchase_count',
|
||||
'zone' => 'warm',
|
||||
'ttl' => null,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — verify hot column exists on listing hot table.
|
||||
*
|
||||
* @return array{ok:bool, message:string}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
global $wpdb;
|
||||
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => 'wpdb global not available',
|
||||
);
|
||||
}
|
||||
$table = $wpdb->prefix . 'wpdo_hot_hp_listing';
|
||||
try {
|
||||
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
return array(
|
||||
'ok' => $exists,
|
||||
'message' => $exists
|
||||
? 'hivepress-marketplace: shares wp_wpdo_hot_hp_listing'
|
||||
: 'hivepress-marketplace: hot listing table missing',
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => sprintf( 'hivepress-marketplace doctor failed: %s', $e->getMessage() ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score.
|
||||
*
|
||||
|
||||
@@ -5,18 +5,13 @@
|
||||
* Two post-type models:
|
||||
*
|
||||
* - Membership_Plan (post_type=hp_membership_plan)
|
||||
* hot fields: hp_expire_period (int), hp_primary (bool)
|
||||
* hp_expire_period (int), hp_primary (bool)
|
||||
*
|
||||
* - Membership (post_type=hp_membership)
|
||||
* hot fields: hp_expired_time (unix ts) — checked on every page load
|
||||
* hp_expired_time (unix ts) — checked on every page load
|
||||
*
|
||||
* Both fields registered to Hot zone so:
|
||||
* - `hp_expired_time < NOW()` lookups become indexed range scans
|
||||
* (replaces O(N) postmeta scan on each request).
|
||||
* - `hp_primary = 1` lookups for "default plan" become O(1).
|
||||
*
|
||||
* Tables `wp_wpdo_hot_hp_membership_plan` and `wp_wpdo_hot_hp_membership`
|
||||
* are created by `TMDO_Schema_Manager` from the registered hot columns.
|
||||
* Flattened by the `hp_membership_plan_core` / `hp_membership_core` entity
|
||||
* groups (TMDO_HivePress_Entity_Groups); the adapter registers no zone fields.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
@@ -62,81 +57,6 @@ if ( ! class_exists( 'TMDO_HivePress_Memberships_Adapter' ) ) {
|
||||
return '2.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hot fields for both post types.
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry Schema registry singleton.
|
||||
*/
|
||||
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
|
||||
$registry->register_many(
|
||||
$this->plugin_slug(),
|
||||
array(
|
||||
// Plan fields.
|
||||
array(
|
||||
'post_type' => 'hp_membership_plan',
|
||||
'meta_key' => 'hp_expire_period',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'int(11) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_expire_period',
|
||||
'indexed' => false,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_membership_plan',
|
||||
'meta_key' => 'hp_primary',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_primary',
|
||||
'indexed' => true,
|
||||
),
|
||||
// Membership (subscription) field — hottest path: every page load.
|
||||
array(
|
||||
'post_type' => 'hp_membership',
|
||||
'meta_key' => 'hp_expired_time',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_expired_time',
|
||||
'indexed' => true,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — verify both hot tables.
|
||||
*
|
||||
* @return array{ok:bool, message:string, details?:array<string,mixed>}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
global $wpdb;
|
||||
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => 'wpdb global not available',
|
||||
);
|
||||
}
|
||||
$details = array();
|
||||
$ok = true;
|
||||
foreach ( array( 'hp_membership_plan', 'hp_membership' ) as $pt ) {
|
||||
$table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $pt );
|
||||
try {
|
||||
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
$details[ $pt ]['table'] = $table;
|
||||
$details[ $pt ]['exists'] = $exists;
|
||||
if ( ! $exists ) {
|
||||
$ok = false;
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
$details[ $pt ]['error'] = $e->getMessage();
|
||||
$ok = false;
|
||||
}
|
||||
}
|
||||
return array(
|
||||
'ok' => $ok,
|
||||
'message' => $ok ? 'hivepress-memberships: hot tables OK' : 'hivepress-memberships: hot tables missing',
|
||||
'details' => $details,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score.
|
||||
*
|
||||
@@ -152,18 +72,6 @@ if ( ! class_exists( 'TMDO_HivePress_Memberships_Adapter' ) ) {
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* FSM modules this adapter contributes.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function migrations(): array {
|
||||
return array(
|
||||
'hot_hp_membership_plan',
|
||||
'hot_hp_membership',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
|
||||
@@ -3,28 +3,12 @@
|
||||
* Messages adapter — `hivepress-messages`.
|
||||
*
|
||||
* The Message model extends Comment (`comment_type = hp_message`) and stores
|
||||
* the recipient_id in `comment_karma` — a creative re-use of an unused column,
|
||||
* but it leaves the field unindexed for "messages received by user N"
|
||||
* queries. Result: every dashboard "unread count" badge does an O(N)
|
||||
* `WHERE comment_type = 'hp_message' AND comment_karma = N` scan over
|
||||
* the entire wp_comments table.
|
||||
* the recipient in `comment_karma`, the sender in `user_id` and the read flag
|
||||
* in `comment_approved`.
|
||||
*
|
||||
* Fix: shadow table `wp_wpdo_comment_hp_message` with proper recipient_id
|
||||
* column + index. Adapter mirrors every hp_message write into the shadow,
|
||||
* and the comment-router rewrites recipient/unread queries to hit the index.
|
||||
*
|
||||
* Schema:
|
||||
*
|
||||
* CREATE TABLE wp_wpdo_comment_hp_message (
|
||||
* comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
* sender_id BIGINT UNSIGNED NOT NULL,
|
||||
* recipient_id BIGINT UNSIGNED NOT NULL,
|
||||
* listing_id BIGINT UNSIGNED NOT NULL,
|
||||
* is_read TINYINT(1) NOT NULL DEFAULT 0,
|
||||
* sent_at DATETIME NOT NULL,
|
||||
* KEY idx_recipient_unread (recipient_id, is_read),
|
||||
* KEY idx_thread (listing_id, sent_at)
|
||||
* );
|
||||
* Nothing is stored in comment meta, so there is no EAV surface to flatten:
|
||||
* the adapter only reports detection and doctor status. (It used to mirror
|
||||
* messages into `wpdo_comment_hp_message`, a table nothing ever created.)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
@@ -37,18 +21,15 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
if ( ! class_exists( 'TMDO_HivePress_Messages_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Messages addon adapter.
|
||||
* Messages addon adapter (detection and doctor only).
|
||||
*/
|
||||
final class TMDO_HivePress_Messages_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/** Comment_type owned by this adapter. */
|
||||
/** Comment_type of the Message model. */
|
||||
public const COMMENT_TYPE = 'hp_message';
|
||||
|
||||
/** Shadow table name (without wpdb prefix). */
|
||||
public const TABLE = 'wpdo_comment_hp_message';
|
||||
|
||||
/**
|
||||
* Constructor — bind register hooks via inherited trait.
|
||||
*/
|
||||
@@ -75,189 +56,6 @@ if ( ! class_exists( 'TMDO_HivePress_Messages_Adapter' ) ) {
|
||||
public function minimum_addon_version(): string {
|
||||
return '1.4.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the shadow table to the Custom_Table_Registry.
|
||||
*
|
||||
* @param TMDO_Custom_Table_Registry $registry Registry singleton.
|
||||
*/
|
||||
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void {
|
||||
$this->register_custom_table(
|
||||
$registry,
|
||||
array(
|
||||
'table_name' => self::TABLE,
|
||||
'primary_key' => 'comment_id',
|
||||
'post_type_link' => null,
|
||||
'expected_columns' => array(
|
||||
'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY',
|
||||
'sender_id' => 'BIGINT UNSIGNED NOT NULL',
|
||||
'recipient_id' => 'BIGINT UNSIGNED NOT NULL',
|
||||
'listing_id' => 'BIGINT UNSIGNED NOT NULL',
|
||||
'is_read' => 'TINYINT(1) NOT NULL DEFAULT 0',
|
||||
'sent_at' => 'DATETIME NOT NULL',
|
||||
),
|
||||
'indexes' => array(
|
||||
'idx_recipient_unread' => '(recipient_id, is_read)',
|
||||
'idx_thread' => '(listing_id, sent_at)',
|
||||
),
|
||||
'doctor_callback' => array( $this, 'doctor_check' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind sync hooks on hp_message comment insert / update / delete.
|
||||
*
|
||||
* The recipient_id is stored in comment_karma by HivePress; we mirror
|
||||
* it into a properly-named indexed column. is_read tracks comment_approved
|
||||
* (HP uses 0=unread / 1=read) — same column, mirrored.
|
||||
*/
|
||||
public function on_register_event_hooks(): void {
|
||||
add_action( 'wp_insert_comment', array( $this, 'mirror_insert' ), 10, 2 );
|
||||
add_action( 'wp_set_comment_status', array( $this, 'mirror_status_change' ), 10, 2 );
|
||||
add_action( 'deleted_comment', array( $this, 'mirror_delete' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a new hp_message into the shadow table.
|
||||
*
|
||||
* @param int $comment_id Comment ID.
|
||||
* @param object|\WP_Comment $comment Comment object.
|
||||
*/
|
||||
public function mirror_insert( int $comment_id, $comment ): void {
|
||||
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$sender_id = (int) ( $comment->user_id ?? 0 );
|
||||
$recipient_id = (int) ( $comment->comment_karma ?? 0 );
|
||||
$listing_id = (int) ( $comment->comment_post_ID ?? 0 );
|
||||
$is_read = (int) ( $comment->comment_approved ?? 0 );
|
||||
if ( $sender_id <= 0 || $recipient_id <= 0 ) {
|
||||
// Malformed message — nothing useful to mirror.
|
||||
return;
|
||||
}
|
||||
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare(
|
||||
'INSERT INTO `' . $wpdb->prefix . self::TABLE . '` (comment_id, sender_id, recipient_id, listing_id, is_read, sent_at) VALUES (%d, %d, %d, %d, %d, %s) ON DUPLICATE KEY UPDATE is_read = VALUES(is_read)',
|
||||
$comment_id,
|
||||
$sender_id,
|
||||
$recipient_id,
|
||||
$listing_id,
|
||||
$is_read,
|
||||
$comment->comment_date ?? current_time( 'mysql' )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update is_read when a message is marked read (comment_approved flip).
|
||||
*
|
||||
* @param int $comment_id Comment ID.
|
||||
* @param string $status New status ('approve', 'hold', 'spam', 'trash').
|
||||
*/
|
||||
public function mirror_status_change( int $comment_id, string $status ): void {
|
||||
global $wpdb;
|
||||
// Look up the comment to confirm it's hp_message before touching.
|
||||
if ( ! function_exists( 'get_comment' ) ) {
|
||||
return;
|
||||
}
|
||||
$comment = get_comment( $comment_id );
|
||||
if ( ! $comment || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
|
||||
return;
|
||||
}
|
||||
$is_read = ( 'approve' === $status ) ? 1 : 0;
|
||||
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prefix . self::TABLE,
|
||||
array( 'is_read' => $is_read ),
|
||||
array( 'comment_id' => $comment_id ),
|
||||
array( '%d' ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove shadow row on hp_message deletion.
|
||||
*
|
||||
* @param int $comment_id Comment ID.
|
||||
* @param object|\WP_Comment $comment Comment object.
|
||||
*/
|
||||
public function mirror_delete( int $comment_id, $comment ): void {
|
||||
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prefix . self::TABLE,
|
||||
array( 'comment_id' => $comment_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — verify shadow table.
|
||||
*
|
||||
* @return array{ok:bool, message:string, details?:array<string,mixed>}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
global $wpdb;
|
||||
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => 'wpdb global not available',
|
||||
);
|
||||
}
|
||||
$table = $wpdb->prefix . self::TABLE;
|
||||
try {
|
||||
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
if ( ! $exists ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => sprintf( 'hivepress-messages: shadow table %s missing', $table ),
|
||||
'details' => array( 'table' => $table ),
|
||||
);
|
||||
}
|
||||
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => sprintf( 'hivepress-messages: %s rows=%d', $table, $rows ),
|
||||
'details' => array(
|
||||
'table' => $table,
|
||||
'rows' => $rows,
|
||||
),
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => sprintf( 'hivepress-messages doctor failed: %s', $e->getMessage() ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score.
|
||||
*
|
||||
* D1: 1.0 — adapter never calls *_meta()
|
||||
* D2: 1.0 — uses wpdb prefix exclusively
|
||||
* D3: 1.0 — shadow table covers recipient/unread access
|
||||
* D4: 1.0 — expected_columns + indexes registered
|
||||
* D5: 1.0 — listens to wp_insert_comment + status change for sync
|
||||
* D6: 1.0 — zero options/transients
|
||||
* D7: 1.0 — only queries own shadow table
|
||||
* D8: 1.0 — idx_recipient_unread covers the hot dashboard query
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* FSM modules this adapter contributes.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function migrations(): array {
|
||||
return array( 'comment_hp_message' );
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
|
||||
@@ -5,25 +5,13 @@
|
||||
* Two related models:
|
||||
*
|
||||
* - Request (post_type=hp_request)
|
||||
* hot fields: hp_drafted (bool), hp_expired_time (unix ts)
|
||||
* — same access pattern as Listing (user dashboard + expiry cron)
|
||||
* hp_drafted / hp_expired_time are flattened by the `hp_draft_flags`
|
||||
* and `hp_request_core` entity groups (TMDO_HivePress_Entity_Groups).
|
||||
*
|
||||
* - Offer (comment_type=hp_offer, parent post_type=hp_request)
|
||||
* comment-column based; benefit comes from comment-router using
|
||||
* (comment_post_ID, comment_type) covering index + dedicated shadow
|
||||
* table for "approved offers per request" aggregation.
|
||||
*
|
||||
* Shadow table for offers (analogous to favorites pattern):
|
||||
*
|
||||
* CREATE TABLE wp_wpdo_comment_hp_offer (
|
||||
* comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
* request_id BIGINT UNSIGNED NOT NULL,
|
||||
* bidder_id BIGINT UNSIGNED NOT NULL,
|
||||
* approved TINYINT(1) NOT NULL DEFAULT 0,
|
||||
* created_at DATETIME NOT NULL,
|
||||
* KEY idx_request_approved (request_id, approved),
|
||||
* KEY idx_bidder (bidder_id)
|
||||
* );
|
||||
* stored in comment columns only — no comment meta to flatten.
|
||||
* (The adapter used to mirror offers into `wpdo_comment_hp_offer`,
|
||||
* a table nothing ever created.)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
@@ -45,9 +33,6 @@ if ( ! class_exists( 'TMDO_HivePress_Requests_Adapter' ) ) {
|
||||
/** Comment_type for the Offer side of Requests. */
|
||||
public const OFFER_COMMENT_TYPE = 'hp_offer';
|
||||
|
||||
/** Shadow table for offers (without wpdb prefix). */
|
||||
public const OFFER_TABLE = 'wpdo_comment_hp_offer';
|
||||
|
||||
/**
|
||||
* Constructor — bind register hooks via inherited trait.
|
||||
*/
|
||||
@@ -74,194 +59,6 @@ if ( ! class_exists( 'TMDO_HivePress_Requests_Adapter' ) ) {
|
||||
public function minimum_addon_version(): string {
|
||||
return '1.2.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hot fields for hp_request post type.
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry Schema registry singleton.
|
||||
*/
|
||||
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
|
||||
$registry->register_many(
|
||||
$this->plugin_slug(),
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'hp_request',
|
||||
'meta_key' => 'hp_drafted',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_drafted',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_request',
|
||||
'meta_key' => 'hp_expired_time',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
|
||||
'column' => 'hp_expired_time',
|
||||
'indexed' => true,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare offer shadow table.
|
||||
*
|
||||
* @param TMDO_Custom_Table_Registry $registry Registry singleton.
|
||||
*/
|
||||
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void {
|
||||
$this->register_custom_table(
|
||||
$registry,
|
||||
array(
|
||||
'table_name' => self::OFFER_TABLE,
|
||||
'primary_key' => 'comment_id',
|
||||
'post_type_link' => 'hp_request',
|
||||
'expected_columns' => array(
|
||||
'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY',
|
||||
'request_id' => 'BIGINT UNSIGNED NOT NULL',
|
||||
'bidder_id' => 'BIGINT UNSIGNED NOT NULL',
|
||||
'approved' => 'TINYINT(1) NOT NULL DEFAULT 0',
|
||||
'created_at' => 'DATETIME NOT NULL',
|
||||
),
|
||||
'indexes' => array(
|
||||
'idx_request_approved' => '(request_id, approved)',
|
||||
'idx_bidder' => '(bidder_id)',
|
||||
),
|
||||
'doctor_callback' => array( $this, 'doctor_check' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror new hp_offer comments into the shadow table.
|
||||
*/
|
||||
public function on_register_event_hooks(): void {
|
||||
add_action( 'wp_insert_comment', array( $this, 'mirror_offer_insert' ), 10, 2 );
|
||||
add_action( 'edit_comment', array( $this, 'mirror_offer_update' ), 10, 2 );
|
||||
add_action( 'deleted_comment', array( $this, 'mirror_offer_delete' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a hp_offer mirror row.
|
||||
*
|
||||
* @param int $comment_id Comment ID.
|
||||
* @param object|\WP_Comment $comment Comment object.
|
||||
*/
|
||||
public function mirror_offer_insert( int $comment_id, $comment ): void {
|
||||
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::OFFER_COMMENT_TYPE ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare(
|
||||
'INSERT INTO `' . $wpdb->prefix . self::OFFER_TABLE . '` (comment_id, request_id, bidder_id, approved, created_at) VALUES (%d, %d, %d, %d, %s) ON DUPLICATE KEY UPDATE approved = VALUES(approved)',
|
||||
$comment_id,
|
||||
(int) ( $comment->comment_post_ID ?? 0 ),
|
||||
(int) ( $comment->user_id ?? 0 ),
|
||||
(int) ( $comment->comment_approved ?? 0 ),
|
||||
$comment->comment_date ?? current_time( 'mysql' )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mirror row when comment is edited (approval flip is the hot path).
|
||||
*
|
||||
* @param int $comment_id Comment ID.
|
||||
* @param array $data Comment data array passed by edit_comment.
|
||||
*/
|
||||
public function mirror_offer_update( int $comment_id, $data ): void {
|
||||
unset( $data );
|
||||
global $wpdb;
|
||||
// Re-fetch comment object for accurate state then re-mirror via insert path.
|
||||
if ( function_exists( 'get_comment' ) ) {
|
||||
$comment = get_comment( $comment_id );
|
||||
if ( $comment ) {
|
||||
$this->mirror_offer_insert( $comment_id, $comment );
|
||||
}
|
||||
}
|
||||
unset( $wpdb );
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove mirror row on hp_offer deletion.
|
||||
*
|
||||
* @param int $comment_id Comment ID.
|
||||
* @param object|\WP_Comment $comment Comment object.
|
||||
*/
|
||||
public function mirror_offer_delete( int $comment_id, $comment ): void {
|
||||
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::OFFER_COMMENT_TYPE ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prefix . self::OFFER_TABLE,
|
||||
array( 'comment_id' => $comment_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — request hot table + offer shadow table.
|
||||
*
|
||||
* @return array{ok:bool, message:string, details?:array<string,mixed>}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
global $wpdb;
|
||||
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => 'wpdb global not available',
|
||||
);
|
||||
}
|
||||
$details = array();
|
||||
$ok = true;
|
||||
foreach ( array(
|
||||
'hp_request_hot' => $wpdb->prefix . 'wpdo_hot_hp_request',
|
||||
'hp_offer_shadow' => $wpdb->prefix . self::OFFER_TABLE,
|
||||
) as $key => $table ) {
|
||||
try {
|
||||
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
$details[ $key ] = array(
|
||||
'table' => $table,
|
||||
'exists' => $exists,
|
||||
);
|
||||
if ( ! $exists ) {
|
||||
$ok = false;
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
$details[ $key ] = array(
|
||||
'table' => $table,
|
||||
'error' => $e->getMessage(),
|
||||
);
|
||||
$ok = false;
|
||||
}
|
||||
}
|
||||
return array(
|
||||
'ok' => $ok,
|
||||
'message' => $ok ? 'hivepress-requests: tables OK' : 'hivepress-requests: one or more tables missing',
|
||||
'details' => $details,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score.
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* FSM modules this adapter contributes.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function migrations(): array {
|
||||
return array(
|
||||
'hot_hp_request',
|
||||
'comment_hp_offer',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
|
||||
@@ -10,14 +10,12 @@
|
||||
* - approved → comment_approved
|
||||
* - listing → comment_post_ID
|
||||
*
|
||||
* No postmeta is used; no entity-bridge group needed. Hot-path optimization
|
||||
* comes from `TMDO_HivePress_Comment_Router` rewriting comment-query SQL to
|
||||
* use `comment_post_ID + comment_type + comment_approved` index, and from
|
||||
* the `wp_wpdo_comment_hp_review` flat table that WPDO Comment entity bridge
|
||||
* already maintains (since v2.13.x).
|
||||
* No postmeta is used; no entity-bridge group needed. Queries use the core
|
||||
* `wp_comments` indexes on `comment_post_ID` / `comment_type`, and the
|
||||
* `wp_wpdo_comment_hp_review` flat table is maintained by the WPDO Comment
|
||||
* entity bridge (since v2.13.x).
|
||||
*
|
||||
* This adapter's role:
|
||||
* - Declare the comment_type to the comment-router
|
||||
* - Doctor probe of the entity-bridge table
|
||||
* - Self-report 8-D suitability score
|
||||
*
|
||||
@@ -124,7 +122,7 @@ if ( ! class_exists( 'TMDO_HivePress_Reviews_Adapter' ) ) {
|
||||
* D5: 1.0 — no cross-adapter writes needed yet
|
||||
* D6: 1.0 — zero options/transients written
|
||||
* D7: 1.0 — only queries own entity-bridge table
|
||||
* D8: 1.0 — comment-router uses (comment_post_ID, comment_type) covering index
|
||||
* D8: 1.0 — reads use the core wp_comments (comment_post_ID, comment_type) indexes
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
/**
|
||||
* Social Links adapter — `hivepress-social-links`.
|
||||
*
|
||||
* Adds social URL fields to vendor profiles (Facebook, Instagram, Twitter,
|
||||
* LinkedIn, YouTube, etc.). Stored in postmeta as `hp_social_*` keys.
|
||||
* The addon adds one attribute per social link the admin enables, separately
|
||||
* 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
|
||||
* (object cache + JSON blob fallback). Listing page reads ~7 social URLs;
|
||||
* caching them as a single blob keyed on vendor_id eliminates 7 postmeta
|
||||
* lookups per render.
|
||||
* (object cache + JSON blob fallback): one blob per post replaces one
|
||||
* postmeta lookup per link on every render.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
@@ -27,25 +27,6 @@ if ( ! class_exists( 'TMDO_HivePress_Social_Links_Adapter' ) ) {
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -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.
|
||||
*/
|
||||
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
|
||||
$fields = array();
|
||||
foreach ( self::SOCIAL_KEYS as $key ) {
|
||||
foreach ( $this->enabled_meta_keys() as $post_type => $keys ) {
|
||||
foreach ( $keys as $key ) {
|
||||
$fields[] = array(
|
||||
'post_type' => 'hp_vendor',
|
||||
'post_type' => $post_type,
|
||||
'meta_key' => $key,
|
||||
'zone' => 'cold',
|
||||
'cache_group' => 'wpdo_cold_hp_vendor',
|
||||
'cache_group' => 'wpdo_cold_' . $post_type,
|
||||
'cache_ttl' => HOUR_IN_SECONDS,
|
||||
);
|
||||
}
|
||||
}
|
||||
$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.
|
||||
*
|
||||
@@ -100,7 +101,7 @@ if ( ! class_exists( 'TMDO_HivePress_Social_Links_Adapter' ) ) {
|
||||
public function doctor_check(): array {
|
||||
return array(
|
||||
'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() ) ) ),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* Usage:
|
||||
* $bridge = new TMDO_HivePress_Attribute_Bridge();
|
||||
* $candidates = $bridge->discover_attributes();
|
||||
* // returns: [['slug' => 'beds', 'type' => 'integer', 'filterable' => true, 'searchable' => true, 'meta_key' => 'hp_listing_beds'], ...]
|
||||
* // returns: [['slug' => 'beds', 'type' => 'integer', 'filterable' => true, 'searchable' => true, 'meta_key' => 'hp_beds'], ...]
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
@@ -123,7 +123,8 @@ if ( ! class_exists( 'TMDO_HivePress_Attribute_Bridge' ) ) {
|
||||
return array(
|
||||
'model' => $model,
|
||||
'slug' => $slug,
|
||||
'meta_key' => sprintf( 'hp_%s_%s', $model, $slug ),
|
||||
// HivePress stores every model's attribute as hp_{slug} (e.g. hp_warranty), not hp_{model}_{slug}.
|
||||
'meta_key' => 'hp_' . $slug,
|
||||
'type' => (string) ( $cfg['edit_field']['type'] ?? $cfg['search_field']['type'] ?? 'text' ),
|
||||
'filterable' => (bool) ( $cfg['filterable'] ?? false ),
|
||||
'sortable' => (bool) ( $cfg['sortable'] ?? false ),
|
||||
|
||||
@@ -118,12 +118,9 @@ if ( ! class_exists( 'TMDO_HivePress_Bootstrap' ) ) {
|
||||
self::bind_adapter( $slug, $version );
|
||||
}
|
||||
|
||||
// Step 5: register cross-cutting components (router + cron optimizer).
|
||||
// Both are gated by their own opt-in option and only activate when
|
||||
// the operator flips it; safe to call here unconditionally.
|
||||
if ( class_exists( 'TMDO_HivePress_Comment_Router' ) ) {
|
||||
TMDO_HivePress_Comment_Router::register();
|
||||
}
|
||||
// Step 5: register the cron optimizer. It is gated by its own opt-in
|
||||
// option and only activates when the operator flips it; safe to call
|
||||
// here unconditionally.
|
||||
if ( class_exists( 'TMDO_HivePress_Cron_Optimizer' ) ) {
|
||||
TMDO_HivePress_Cron_Optimizer::register();
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Comment query router for HivePress comment-type models.
|
||||
*
|
||||
* Rewrites `WP_Comment_Query` SQL clauses to JOIN the per-addon shadow tables
|
||||
* built by Sprint 2 adapters. Currently handles four `comment_type` values:
|
||||
*
|
||||
* hp_message → wp_wpdo_comment_hp_message (recipient_id, is_read indexed)
|
||||
* hp_favorite → wp_wpdo_comment_hp_favorite (UNIQUE user_id+listing_id)
|
||||
* hp_offer → wp_wpdo_comment_hp_offer (request_id, approved indexed)
|
||||
* hp_review → wp_wpdo_comment_hp_review (managed by entity-bridge)
|
||||
*
|
||||
* Disabled by default. Operator opts in via:
|
||||
*
|
||||
* wp option update wpdo_hivepress_comment_router_enabled 1
|
||||
*
|
||||
* Once enabled, comment queries with `type` matching one of the above gain a
|
||||
* shadow-table LEFT JOIN at `comments_clauses` filter time. The JOIN is
|
||||
* deliberately LEFT so missing shadow rows (during dual_write phase before
|
||||
* backfill completes) don't drop legitimate comments.
|
||||
*
|
||||
* Per Karpathy guideline: this router only handles the four comment_types
|
||||
* Sprint 2 actually needs — no extension hooks for "future addons".
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Comment_Router' ) ) {
|
||||
|
||||
/**
|
||||
* Comment query rewriter.
|
||||
*
|
||||
* Single-instance, registered via static `register()`. Stateless apart from
|
||||
* the option-cached enabled flag.
|
||||
*/
|
||||
final class TMDO_HivePress_Comment_Router {
|
||||
|
||||
/** Option key controlling the rewriter. */
|
||||
public const OPTION_ENABLED = 'wpdo_hivepress_comment_router_enabled';
|
||||
|
||||
/**
|
||||
* Map of comment_type → shadow table (without wpdb prefix).
|
||||
*
|
||||
* @var array<string,string>
|
||||
*/
|
||||
private const SHADOW_MAP = array(
|
||||
'hp_message' => 'wpdo_comment_hp_message',
|
||||
'hp_favorite' => 'wpdo_comment_hp_favorite',
|
||||
'hp_offer' => 'wpdo_comment_hp_offer',
|
||||
'hp_review' => 'wpdo_comment_hp_review',
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether the router is bound this request.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static bool $bound = false;
|
||||
|
||||
/**
|
||||
* Bind the comments_clauses filter (idempotent).
|
||||
*
|
||||
* Bootstrap calls this from each adapter's on_register_query_hooks()
|
||||
* so router activation follows the same gate as adapter binding.
|
||||
*/
|
||||
public static function register(): void {
|
||||
if ( self::$bound ) {
|
||||
return;
|
||||
}
|
||||
if ( ! self::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
add_filter( 'comments_clauses', array( __CLASS__, 'rewrite' ), 20, 2 );
|
||||
self::$bound = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset internal state (test only).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function reset_for_tests(): void {
|
||||
self::$bound = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether router is enabled via the operator option.
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
if ( ! function_exists( 'get_option' ) ) {
|
||||
return false;
|
||||
}
|
||||
return (bool) (int) get_option( self::OPTION_ENABLED, 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: rewrite comments_clauses for known hp comment types.
|
||||
*
|
||||
* @param array $clauses SQL clauses (join, where, fields, ...).
|
||||
* @param \WP_Comment_Query $query The query object.
|
||||
* @return array Possibly-modified clauses.
|
||||
*/
|
||||
public static function rewrite( array $clauses, $query ): array {
|
||||
$type = self::query_comment_type( $query );
|
||||
if ( '' === $type || ! isset( self::SHADOW_MAP[ $type ] ) ) {
|
||||
return $clauses;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$shadow = $wpdb->prefix . self::SHADOW_MAP[ $type ];
|
||||
$alias = 'wpdo_shadow';
|
||||
|
||||
// LEFT JOIN keeps row visibility when shadow lags (during backfill).
|
||||
$join = ( $clauses['join'] ?? '' ) . " LEFT JOIN `{$shadow}` AS {$alias} ON {$alias}.comment_id = {$wpdb->comments}.comment_ID";
|
||||
|
||||
// Filter pushdown: hp_message recipient lookup uses comment_karma in HP.
|
||||
// We rewrite WHERE clauses that filter by comment_karma (= recipient hack)
|
||||
// to use the indexed shadow column.
|
||||
$where = (string) ( $clauses['where'] ?? '' );
|
||||
if ( 'hp_message' === $type && '' !== $where ) {
|
||||
$where = preg_replace(
|
||||
'/' . preg_quote( $wpdb->comments, '/' ) . '\.comment_karma\s*=\s*(\d+)/',
|
||||
$alias . '.recipient_id = $1',
|
||||
$where
|
||||
);
|
||||
}
|
||||
|
||||
$clauses['join'] = $join;
|
||||
$clauses['where'] = $where;
|
||||
|
||||
/**
|
||||
* Filter: wpdo_hivepress_comment_router_clauses
|
||||
*
|
||||
* Allows fine-grained tweaks per comment_type. Intended for adapter
|
||||
* tests + future custom-column lookups.
|
||||
*
|
||||
* @param array $clauses Modified clauses.
|
||||
* @param string $type Matched comment_type.
|
||||
* @param string $alias Shadow table alias.
|
||||
*/
|
||||
return apply_filters( 'wpdo_hivepress_comment_router_clauses', $clauses, $type, $alias );
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the comment_type from a WP_Comment_Query.
|
||||
*
|
||||
* @param \WP_Comment_Query $query Query object.
|
||||
*/
|
||||
private static function query_comment_type( $query ): string {
|
||||
if ( ! is_object( $query ) || ! isset( $query->query_vars ) ) {
|
||||
return '';
|
||||
}
|
||||
$type = $query->query_vars['type'] ?? '';
|
||||
if ( is_array( $type ) ) {
|
||||
$type = reset( $type );
|
||||
}
|
||||
return is_string( $type ) ? $type : '';
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -10,9 +10,8 @@
|
||||
*
|
||||
* Strategy: detect, log, render admin notice with guidance, AND short-circuit
|
||||
* the WPDO HivePress bootstrap via `wpdo_hivepress_should_bind` filter so
|
||||
* adapters are NOT bound. Operator must explicitly run
|
||||
* `wp wpdo hivepress migrate-from-hpct` to switch to WPDO and deactivate
|
||||
* the legacy plugins.
|
||||
* adapters are NOT bound. The operator deactivates the legacy plugins to let
|
||||
* WPDO take over; data kept in their own tables is not imported.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
@@ -163,7 +162,7 @@ if ( ! class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
|
||||
|
||||
$message = sprintf(
|
||||
/* translators: %s: comma-separated list of conflicting plugin names. */
|
||||
__( 'wp-data-optimizer detected legacy HivePress data-layer plugins: %s. WPDO HivePress integration is paused to prevent dual-write corruption. Run `wp wpdo hivepress migrate-from-hpct` to migrate, then deactivate the legacy plugins.', 'tmdo-hivepress' ),
|
||||
__( 'wp-data-optimizer detected legacy HivePress data-layer plugins: %s. WPDO HivePress integration is paused to prevent dual-write corruption. Deactivate the legacy plugins to let WPDO take over (data in their own tables is not imported).', 'tmdo-hivepress' ),
|
||||
implode( ', ', $names )
|
||||
);
|
||||
|
||||
@@ -206,7 +205,7 @@ if ( ! class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'Conflict detected: %s. wp-data-optimizer fully supersedes these. Run: wp wpdo hivepress migrate-from-hpct',
|
||||
'Conflict detected: %s. wp-data-optimizer fully supersedes these. Deactivate them to let WPDO take over.',
|
||||
implode( ', ', $conflicts )
|
||||
);
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ if ( ! interface_exists( 'TMDO_HivePress_Adapter' ) ) {
|
||||
*
|
||||
* Adapters with hot zone fields used in search/filter MUST register a
|
||||
* `pre_get_posts` / `pre_get_comments` filter that defers to
|
||||
* `TMDO_HivePress_Query_Router` / `..._Comment_Router`.
|
||||
* `TMDO_HivePress_Query_Router`.
|
||||
*
|
||||
* Called on `init:5` after Schema_Registry is fully populated.
|
||||
*/
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Favorites interceptor for HivePress user favorites.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Favorites interceptor — syncs HivePress user favorites to hpct_favorites table.
|
||||
*
|
||||
* Special: operates on user_meta (hp_favorited_listings), not post_meta.
|
||||
*/
|
||||
class TMDO_Favorites_Interceptor extends TMDO_Interceptor_Base {
|
||||
|
||||
/**
|
||||
* Module identifier.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $module = 'favorites';
|
||||
|
||||
/**
|
||||
* Registers WordPress hooks for this interceptor.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_filter( 'update_user_metadata', array( $this, 'filter_update_user_meta' ), 10, 5 );
|
||||
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters update_user_metadata to sync hp_favorited_listings.
|
||||
*
|
||||
* @param mixed $check Whether to short-circuit.
|
||||
* @param int $user_id User ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
* @param mixed $prev_value Previous meta value.
|
||||
* @return mixed Filtered check value.
|
||||
*/
|
||||
public function filter_update_user_meta( $check, int $user_id, string $meta_key, $meta_value, $prev_value ) {
|
||||
if ( 'hp_favorited_listings' !== $meta_key || ! $this->is_active() ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->sync_favorites( $user_id, $meta_value );
|
||||
} catch ( \Throwable $e ) {
|
||||
TMDO_Logger::error( $this->module, 'update_user_metadata', $e->getMessage() );
|
||||
}
|
||||
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes favorites records when a listing post is deleted.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param \WP_Post $post Post object.
|
||||
* @return void
|
||||
*/
|
||||
public function action_delete_post( int $post_id, \WP_Post $post ): void {
|
||||
if ( 'hp_listing' !== $post->post_type || ! $this->is_active() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
global $wpdb;
|
||||
$wpdb->delete( TMDO_DB::table( 'hpct_favorites' ), array( 'listing_id' => $post_id ), array( '%d' ) );
|
||||
} catch ( \Throwable $e ) {
|
||||
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs user favorites to the hpct_favorites table.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @param mixed $meta_value Favorites meta value (array or serialized).
|
||||
* @return void
|
||||
*/
|
||||
private function sync_favorites( int $user_id, $meta_value ): void {
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( 'hpct_favorites' );
|
||||
$now = TMDO_DB::now();
|
||||
|
||||
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
|
||||
// Input is user-controlled wp_usermeta value via update_user_meta hook.
|
||||
$listing_ids = is_array( $meta_value )
|
||||
? $meta_value
|
||||
: (array) TMDO_Safe_Unserialize::run( $meta_value );
|
||||
$listing_ids = array_filter( array_map( 'absint', $listing_ids ) );
|
||||
|
||||
// Delete all existing and re-insert.
|
||||
$wpdb->delete( $table, array( 'user_id' => $user_id ), array( '%d' ) );
|
||||
|
||||
$insert_sql = TMDO_IS_SQLITE ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- insert_sql is 'INSERT IGNORE'/'INSERT OR IGNORE'; table name from TMDO_DB::table().
|
||||
foreach ( $listing_ids as $listing_id ) {
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"{$insert_sql} INTO `{$table}` (user_id, listing_id, created_at) VALUES (%d, %d, %s)",
|
||||
$user_id,
|
||||
$listing_id,
|
||||
$now
|
||||
)
|
||||
);
|
||||
}
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Listing Meta interceptor for hp_listing post type.
|
||||
*
|
||||
* Intercepts get/update_post_metadata for hp_listing posts.
|
||||
* Operates on hpct_listing_meta KV table. Only intercepts meta_keys
|
||||
* prefixed with hp_ or _hp_.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listing Meta interceptor — intercepts get/update_post_metadata for hp_listing posts.
|
||||
*
|
||||
* Operates on hpct_listing_meta KV table. Only intercepts meta_keys
|
||||
* prefixed with hp_ or _hp_.
|
||||
*/
|
||||
class TMDO_Listing_Meta_Interceptor extends TMDO_Interceptor_Base {
|
||||
|
||||
/**
|
||||
* Module identifier.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $module = 'listing_meta';
|
||||
|
||||
/**
|
||||
* Registers WordPress hooks for this interceptor.
|
||||
*
|
||||
* Skips registration when the underlying `hpct_listing_meta` KV table is
|
||||
* missing — this prevents cascading DB errors when WPDO is installed
|
||||
* without HPCT first having been imported. Solves audit finding R-2.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
if ( ! self::table_exists() ) {
|
||||
// HPCT is not installed at all → don't even log (clean doctor output).
|
||||
// We only log when HPCT *is* loaded but its table is missing — that's a
|
||||
// genuine inconsistency worth surfacing to admins.
|
||||
if ( class_exists( 'HPCT_Core' ) ) {
|
||||
self::log_skip_once();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
add_filter( 'get_post_metadata', array( $this, 'filter_get_meta' ), 10, 4 );
|
||||
add_filter( 'update_post_metadata', array( $this, 'filter_update_meta' ), 10, 5 );
|
||||
add_filter( 'add_post_metadata', array( $this, 'filter_add_meta' ), 10, 5 );
|
||||
add_filter( 'delete_post_metadata', array( $this, 'filter_delete_meta' ), 10, 5 );
|
||||
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached check for the existence of the hpct_listing_meta table.
|
||||
*
|
||||
* Result is request-cached to avoid repeated SHOW TABLES calls.
|
||||
*
|
||||
* @return bool True when the table exists.
|
||||
*/
|
||||
private static function table_exists(): bool {
|
||||
static $exists = null;
|
||||
if ( null !== $exists ) {
|
||||
return $exists;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( 'hpct_listing_meta' );
|
||||
// SHOW TABLES LIKE returns the table name when present, or NULL when absent.
|
||||
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
$exists = ( null !== $found && '' !== $found );
|
||||
return $exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset cached table_exists state — for tests and PR-3 schema changes.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function reset_table_exists_cache(): void {
|
||||
// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
|
||||
// Use reflection to clear the static. Cleanest approach in PHP 8.1+.
|
||||
( function () {
|
||||
static $exists = null;
|
||||
$exists = null;
|
||||
} )();
|
||||
// The above closure does not actually reset the bound static of table_exists().
|
||||
// Instead we expose a flag via a class-level static.
|
||||
self::$table_exists_cache_invalidated_at = microtime( true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker for cache invalidation. Real reset happens by re-calling table_exists()
|
||||
* in a fresh process; tests should isolate via runInSeparateProcess where needed.
|
||||
*
|
||||
* @var float
|
||||
*/
|
||||
private static float $table_exists_cache_invalidated_at = 0.0;
|
||||
|
||||
/**
|
||||
* Records a single "skipped — table missing" entry in wpdo_errors per request.
|
||||
*
|
||||
* Direct INSERT (not via TMDO_Logger::error) so we avoid cascading the
|
||||
* message to PHP's error_log on every page load. Idempotent within a
|
||||
* single request via static guard.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function log_skip_once(): void {
|
||||
static $logged = false;
|
||||
if ( $logged ) {
|
||||
return;
|
||||
}
|
||||
$logged = true;
|
||||
|
||||
// Direct INSERT — wrapped in try/catch because the wpdo_errors table
|
||||
// might not exist in fresh installs. Skipping the log is acceptable;
|
||||
// breaking register_hooks() is not.
|
||||
try {
|
||||
global $wpdb;
|
||||
$wpdb->insert(
|
||||
$wpdb->prefix . 'wpdo_errors',
|
||||
array(
|
||||
'module' => 'listing_meta',
|
||||
'zone' => '',
|
||||
'hook' => 'register_hooks',
|
||||
'message' => 'Skipped: hpct_listing_meta table missing. Run `wp wpdo import-hpct` or migrate first.',
|
||||
'context' => '{}',
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- intentional: wpdo_errors absence is non-critical.
|
||||
// Silently swallow — wpdo_errors table absence is non-critical.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes listing meta when a post is deleted.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param \WP_Post $post Post object.
|
||||
* @return void
|
||||
*/
|
||||
public function action_delete_post( int $post_id, \WP_Post $post ): void {
|
||||
if ( 'hp_listing' !== $post->post_type || ! $this->is_active() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
global $wpdb;
|
||||
$wpdb->delete( TMDO_DB::table( 'hpct_listing_meta' ), array( 'listing_id' => $post_id ), array( '%d' ) );
|
||||
} catch ( \Throwable $e ) {
|
||||
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters get_post_metadata for hp_listing posts.
|
||||
*
|
||||
* @param mixed $value Current value or null.
|
||||
* @param int $object_id Post ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param bool $single Whether to return single value.
|
||||
* @return mixed Filtered value.
|
||||
*/
|
||||
public function filter_get_meta( $value, int $object_id, string $meta_key, bool $single ) {
|
||||
if ( ! $this->is_enabled() || ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $this->intercept(
|
||||
function () use ( $object_id, $meta_key, $single ) {
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( 'hpct_listing_meta' );
|
||||
|
||||
if ( $single ) {
|
||||
$val = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name validated by TMDO_DB::table() + sanitize_key().
|
||||
$object_id,
|
||||
$meta_key
|
||||
)
|
||||
);
|
||||
return null !== $val ? $val : null;
|
||||
}
|
||||
|
||||
return $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name validated by TMDO_DB::table() + sanitize_key().
|
||||
$object_id,
|
||||
$meta_key
|
||||
)
|
||||
) ?: null;
|
||||
},
|
||||
fn() => $value,
|
||||
'get_post_metadata'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters update_post_metadata for hp_listing posts.
|
||||
*
|
||||
* @param mixed $check Whether to short-circuit.
|
||||
* @param int $object_id Post ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
* @param mixed $prev_value Previous meta value.
|
||||
* @return mixed Filtered check value.
|
||||
*/
|
||||
public function filter_update_meta( $check, int $object_id, string $meta_key, $meta_value, $prev_value ) {
|
||||
if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->upsert_meta( $object_id, $meta_key, $meta_value );
|
||||
} catch ( \Throwable $e ) {
|
||||
TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() );
|
||||
}
|
||||
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters add_post_metadata for hp_listing posts.
|
||||
*
|
||||
* @param mixed $check Whether to short-circuit.
|
||||
* @param int $object_id Post ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
* @param bool $unique Whether meta key should be unique.
|
||||
* @return mixed Filtered check value.
|
||||
*/
|
||||
public function filter_add_meta( $check, int $object_id, string $meta_key, $meta_value, bool $unique ) {
|
||||
if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->upsert_meta( $object_id, $meta_key, $meta_value );
|
||||
} catch ( \Throwable $e ) {
|
||||
TMDO_Logger::error( $this->module, 'add_post_metadata', $e->getMessage() );
|
||||
}
|
||||
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters delete_post_metadata for hp_listing posts.
|
||||
*
|
||||
* @param mixed $check Whether to short-circuit.
|
||||
* @param int $object_id Post ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value to match.
|
||||
* @param bool $delete_all Whether to delete all matching.
|
||||
* @return mixed Filtered check value.
|
||||
*/
|
||||
public function filter_delete_meta( $check, int $object_id, string $meta_key, $meta_value, bool $delete_all ) {
|
||||
if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
try {
|
||||
global $wpdb;
|
||||
$wpdb->delete(
|
||||
TMDO_DB::table( 'hpct_listing_meta' ),
|
||||
array(
|
||||
'listing_id' => $object_id,
|
||||
'meta_key' => $meta_key,
|
||||
),
|
||||
array( '%d', '%s' )
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
TMDO_Logger::error( $this->module, 'delete_post_metadata', $e->getMessage() );
|
||||
}
|
||||
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the post is an hp_listing.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return bool True if hp_listing post type.
|
||||
*/
|
||||
private function is_hp_listing( int $post_id ): bool {
|
||||
return 'hp_listing' === get_post_type( $post_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the meta key is an hp_ or _hp_ key.
|
||||
*
|
||||
* @param string $meta_key Meta key.
|
||||
* @return bool True if key starts with hp_ or _hp_.
|
||||
*/
|
||||
private function is_hp_key( string $meta_key ): bool {
|
||||
return str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts or updates a listing meta value in the hpct_listing_meta table.
|
||||
*
|
||||
* Single round-trip via TMDO_DB::upsert() — uses ON DUPLICATE KEY UPDATE
|
||||
* (MySQL) or ON CONFLICT(listing_id, meta_key) DO UPDATE (SQLite).
|
||||
* Solves audit finding P-C1 (the previous SELECT + INSERT/UPDATE pattern
|
||||
* cost 2 SQL round-trips per write).
|
||||
*
|
||||
* Requires UNIQUE KEY (listing_id, meta_key) on hpct_listing_meta —
|
||||
* present by design from HPCT v1.0+.
|
||||
*
|
||||
* @param int $listing_id Listing post ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
* @return void
|
||||
*/
|
||||
private function upsert_meta( int $listing_id, string $meta_key, $meta_value ): void {
|
||||
// maybe_serialize array values to match WordPress's native postmeta semantics.
|
||||
$serialized = is_array( $meta_value ) || is_object( $meta_value )
|
||||
? maybe_serialize( $meta_value )
|
||||
: (string) $meta_value;
|
||||
|
||||
TMDO_DB::upsert(
|
||||
TMDO_DB::table( 'hpct_listing_meta' ),
|
||||
array(
|
||||
'listing_id' => $listing_id,
|
||||
'meta_key' => $meta_key,
|
||||
'meta_value' => $serialized,
|
||||
),
|
||||
array( 'meta_value' ), // Update only meta_value on conflict.
|
||||
array( 'listing_id', 'meta_key' ), // Composite unique key.
|
||||
array( '%d', '%s', '%s' )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Memberships interceptor for HivePress Membership posts.
|
||||
*
|
||||
* @package TMDO
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memberships interceptor — intercepts HivePress Membership reads/writes.
|
||||
*
|
||||
* Operates on hpct_memberships table.
|
||||
*/
|
||||
class TMDO_Memberships_Interceptor extends TMDO_Standard_Post_Interceptor {
|
||||
|
||||
/**
|
||||
* Module identifier.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $module = 'memberships';
|
||||
|
||||
/**
|
||||
* Meta key → flat column mapping.
|
||||
*/
|
||||
public const FIELD_MAP = array(
|
||||
'hp_plan' => 'plan_id',
|
||||
'hp_user' => 'user_id',
|
||||
'hp_price' => 'price',
|
||||
'hp_order' => 'order_id',
|
||||
'hp_start_date' => 'start_date',
|
||||
'hp_end_date' => 'end_date',
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the WordPress post_type slug handled by this interceptor.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_post_type(): string {
|
||||
return 'hp_membership';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the table key passed to TMDO_DB::table().
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_table_key(): string {
|
||||
return 'hpct_memberships';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the INSERT row for a newly created membership post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param \WP_Post $post Post object.
|
||||
* @param string $now Formatted datetime string.
|
||||
* @return array{values: array, formats: array}
|
||||
*/
|
||||
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
|
||||
return array(
|
||||
'values' => array(
|
||||
'post_id' => $post_id,
|
||||
'user_id' => (int) $post->post_author,
|
||||
'status' => $post->post_status,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
),
|
||||
'formats' => array( '%d', '%d', '%s', '%s', '%s' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Messages interceptor for HivePress Message posts.
|
||||
*
|
||||
* @package TMDO
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages interceptor — intercepts HivePress Message reads/writes.
|
||||
*
|
||||
* Operates on hpct_messages table.
|
||||
*/
|
||||
class TMDO_Messages_Interceptor extends TMDO_Standard_Post_Interceptor {
|
||||
|
||||
/**
|
||||
* Module identifier.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $module = 'messages';
|
||||
|
||||
/**
|
||||
* Meta key → flat column mapping.
|
||||
*/
|
||||
public const FIELD_MAP = array(
|
||||
'hp_sender' => 'sender_id',
|
||||
'hp_recipient' => 'recipient_id',
|
||||
'hp_listing' => 'listing_id',
|
||||
'hp_read' => 'is_read',
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the WordPress post_type slug handled by this interceptor.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_post_type(): string {
|
||||
return 'hp_message';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the table key passed to TMDO_DB::table().
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_table_key(): string {
|
||||
return 'hpct_messages';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the INSERT row for a newly created message post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param \WP_Post $post Post object.
|
||||
* @param string $now Formatted datetime string.
|
||||
* @return array{values: array, formats: array}
|
||||
*/
|
||||
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
|
||||
return array(
|
||||
'values' => array(
|
||||
'post_id' => $post_id,
|
||||
'thread_id' => (int) $post->post_parent,
|
||||
'sender_id' => (int) $post->post_author,
|
||||
'subject' => sanitize_text_field( $post->post_title ),
|
||||
'body' => $post->post_content,
|
||||
'status' => $post->post_status,
|
||||
'sent_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
),
|
||||
'formats' => array( '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Requests interceptor for HivePress Request posts.
|
||||
*
|
||||
* @package TMDO
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests interceptor — intercepts HivePress Request reads/writes.
|
||||
*
|
||||
* Operates on hpct_requests table.
|
||||
*/
|
||||
class TMDO_Requests_Interceptor extends TMDO_Standard_Post_Interceptor {
|
||||
|
||||
/**
|
||||
* Module identifier.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $module = 'requests';
|
||||
|
||||
/**
|
||||
* Meta key → flat column mapping.
|
||||
*/
|
||||
public const FIELD_MAP = array(
|
||||
'hp_vendor' => 'vendor_id',
|
||||
'hp_listing' => 'listing_id',
|
||||
'hp_budget' => 'budget',
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the WordPress post_type slug handled by this interceptor.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_post_type(): string {
|
||||
return 'hp_request';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the table key passed to TMDO_DB::table().
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_table_key(): string {
|
||||
return 'hpct_requests';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the INSERT row for a newly created request post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param \WP_Post $post Post object.
|
||||
* @param string $now Formatted datetime string.
|
||||
* @return array{values: array, formats: array}
|
||||
*/
|
||||
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
|
||||
return array(
|
||||
'values' => array(
|
||||
'post_id' => $post_id,
|
||||
'user_id' => (int) $post->post_author,
|
||||
'status' => $post->post_status,
|
||||
'message' => $post->post_content,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
),
|
||||
'formats' => array( '%d', '%d', '%s', '%s', '%s', '%s' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Reviews interceptor for HivePress Review posts.
|
||||
*
|
||||
* @package TMDO
|
||||
*/
|
||||
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reviews interceptor — intercepts HivePress Review reads/writes.
|
||||
*
|
||||
* Operates on hpct_reviews table.
|
||||
*/
|
||||
class TMDO_Reviews_Interceptor extends TMDO_Standard_Post_Interceptor {
|
||||
|
||||
/**
|
||||
* Module identifier.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $module = 'reviews';
|
||||
|
||||
/**
|
||||
* Meta key → flat column mapping.
|
||||
*/
|
||||
public const FIELD_MAP = array(
|
||||
'hp_rating' => 'rating',
|
||||
'hp_listing' => 'listing_id',
|
||||
'_hp_vendor' => 'vendor_id',
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the WordPress post_type slug handled by this interceptor.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_post_type(): string {
|
||||
return 'hp_review';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the table key passed to TMDO_DB::table().
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_table_key(): string {
|
||||
return 'hpct_reviews';
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers WordPress hooks, including the extra get_post_metadata hook.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
parent::register_hooks();
|
||||
add_filter( 'get_post_metadata', array( $this, 'filter_get_meta' ), 10, 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the INSERT row for a newly created review post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param \WP_Post $post Post object.
|
||||
* @param string $now Formatted datetime string.
|
||||
* @return array{values: array, formats: array}
|
||||
*/
|
||||
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
|
||||
return array(
|
||||
'values' => array(
|
||||
'post_id' => $post_id,
|
||||
'user_id' => (int) $post->post_author,
|
||||
'status' => $post->post_status,
|
||||
'title' => sanitize_text_field( $post->post_title ),
|
||||
'content' => $post->post_content,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
),
|
||||
'formats' => array( '%d', '%d', '%s', '%s', '%s', '%s', '%s' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads hp_rating directly from the reviews flat table.
|
||||
*
|
||||
* @param mixed $value Short-circuit value.
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param bool $single Whether to return a single value.
|
||||
* @return mixed
|
||||
*/
|
||||
public function filter_get_meta( $value, int $post_id, string $meta_key, bool $single ) {
|
||||
if ( ! $this->is_enabled() || 'hp_rating' !== $meta_key || 'hp_review' !== get_post_type( $post_id ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $this->intercept(
|
||||
function () use ( $post_id, $single ) {
|
||||
global $wpdb;
|
||||
$rating = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
'SELECT rating FROM ' . TMDO_DB::table( 'hpct_reviews' ) . ' WHERE post_id = %d LIMIT 1',
|
||||
$post_id
|
||||
)
|
||||
);
|
||||
return null !== $rating ? ( $single ? $rating : array( $rating ) ) : null;
|
||||
},
|
||||
fn() => $value,
|
||||
'get_post_metadata'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Statistics interceptor for listing view tracking.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics interceptor — tracks listing view events in hpct_statistics.
|
||||
*
|
||||
* Special: append-only (INSERT only, no UPDATE). Records events from
|
||||
* both _hp_views meta update and HivePress listing/viewed action.
|
||||
*/
|
||||
class TMDO_Statistics_Interceptor extends TMDO_Interceptor_Base {
|
||||
|
||||
/**
|
||||
* Module identifier.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $module = 'statistics';
|
||||
|
||||
/**
|
||||
* Registers WordPress hooks for this interceptor.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_filter( 'update_post_metadata', array( $this, 'filter_view_meta' ), 10, 5 );
|
||||
add_action( 'hivepress/v1/models/listing/viewed', array( $this, 'action_listing_viewed' ), 10, 1 );
|
||||
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters update_post_metadata to record a view event when _hp_views is updated.
|
||||
*
|
||||
* @param mixed $check Whether to short-circuit.
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
* @param mixed $prev_value Previous meta value.
|
||||
* @return mixed Filtered check value.
|
||||
*/
|
||||
public function filter_view_meta( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) {
|
||||
if ( '_hp_views' !== $meta_key || 'hp_listing' !== get_post_type( $post_id ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
if ( ! $this->is_active() ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->record_event( $post_id, 'view' );
|
||||
} catch ( \Throwable $e ) {
|
||||
TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() );
|
||||
}
|
||||
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a view event when a HivePress listing is viewed.
|
||||
*
|
||||
* @param mixed $listing Listing object or ID.
|
||||
* @return void
|
||||
*/
|
||||
public function action_listing_viewed( $listing ): void {
|
||||
if ( ! $this->is_active() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$listing_id = is_object( $listing ) && method_exists( $listing, 'get_id' )
|
||||
? $listing->get_id()
|
||||
: ( is_numeric( $listing ) ? (int) $listing : 0 );
|
||||
|
||||
if ( ! $listing_id ) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->record_event( $listing_id, 'hp_action_view' );
|
||||
} catch ( \Throwable $e ) {
|
||||
TMDO_Logger::error( $this->module, 'listing_viewed', $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes statistics records when a listing post is deleted.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param \WP_Post $post Post object.
|
||||
* @return void
|
||||
*/
|
||||
public function action_delete_post( int $post_id, \WP_Post $post ): void {
|
||||
if ( 'hp_listing' !== $post->post_type || ! $this->is_active() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
global $wpdb;
|
||||
$wpdb->delete( TMDO_DB::table( 'hpct_statistics' ), array( 'listing_id' => $post_id ), array( '%d' ) );
|
||||
} catch ( \Throwable $e ) {
|
||||
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a statistics event to the hpct_statistics table.
|
||||
*
|
||||
* @param int $listing_id Listing post ID.
|
||||
* @param string $event_type Event type identifier.
|
||||
* @return void
|
||||
*/
|
||||
private function record_event( int $listing_id, string $event_type ): void {
|
||||
global $wpdb;
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
|
||||
$ip_hash = $ip ? hash( 'sha256', $ip ) : '';
|
||||
|
||||
$wpdb->insert(
|
||||
TMDO_DB::table( 'hpct_statistics' ),
|
||||
array(
|
||||
'listing_id' => $listing_id,
|
||||
'event_type' => $event_type,
|
||||
'user_id' => $user_id,
|
||||
'ip_hash' => $ip_hash,
|
||||
'referrer' => substr( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ?? '' ) ), 0, 500 ),
|
||||
'created_at' => TMDO_DB::now(),
|
||||
),
|
||||
array( '%d', '%s', '%d', '%s', '%s', '%s' )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* WP_Query interceptor for hp_listing meta queries.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* WP_Query interceptor for hp_listing meta queries.
|
||||
*
|
||||
* The hpct_listing_meta is a key-value table (like wp_postmeta), so each
|
||||
* meta_query condition needs its own JOIN alias with a meta_key condition:
|
||||
*
|
||||
* LEFT JOIN wp_hpct_listing_meta AS wpdo_lm_0
|
||||
* ON (wp_posts.ID = wpdo_lm_0.listing_id AND wpdo_lm_0.meta_key = 'hp_price')
|
||||
* WHERE wpdo_lm_0.meta_value >= 100
|
||||
*
|
||||
* All hp_* and _hp_* meta keys are intercepted. Any other meta keys are
|
||||
* left in the meta_query to be handled by wp_postmeta as usual.
|
||||
*/
|
||||
class TMDO_Listing_Meta_Query extends TMDO_Query_Interceptor_Base {
|
||||
|
||||
/**
|
||||
* Returns the module identifier.
|
||||
*
|
||||
* @return string Module name.
|
||||
*/
|
||||
protected function get_module(): string {
|
||||
return 'listing_meta';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the post types this interceptor handles.
|
||||
*
|
||||
* @return string[] Post type slugs.
|
||||
*/
|
||||
protected function get_post_types(): array {
|
||||
return array( 'hp_listing' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the custom table name.
|
||||
*
|
||||
* @return string Table name without prefix.
|
||||
*/
|
||||
protected function get_table(): string {
|
||||
return 'hpct_listing_meta';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the join column for this KV table.
|
||||
*
|
||||
* @return string Join column name.
|
||||
*/
|
||||
protected function get_join_column(): string {
|
||||
return 'listing_id';
|
||||
}
|
||||
|
||||
/**
|
||||
* Not used — this class overrides pre_get_posts directly.
|
||||
*
|
||||
* @return array Empty array.
|
||||
*/
|
||||
protected function get_meta_key_map(): array {
|
||||
return array();
|
||||
}
|
||||
|
||||
// ── Override pre_get_posts for KV-table pattern ───────────────────────
|
||||
|
||||
/**
|
||||
* Intercepts pre_get_posts to handle KV-table meta_query conditions.
|
||||
*
|
||||
* @param \WP_Query $query The WP_Query object.
|
||||
* @return void
|
||||
*/
|
||||
public function pre_get_posts( \WP_Query $query ): void {
|
||||
if ( ! $this->should_intercept( $query ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$raw_meta_query = (array) $query->get( 'meta_query' );
|
||||
if ( empty( $raw_meta_query ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$our_clauses = array();
|
||||
$remaining = array();
|
||||
|
||||
foreach ( $raw_meta_query as $k => $clause ) {
|
||||
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
|
||||
$remaining[ $k ] = $clause;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $this->is_hp_key( $clause['key'] ) ) {
|
||||
$our_clauses[] = array(
|
||||
'meta_key' => $clause['key'],
|
||||
'value' => $clause['value'] ?? '',
|
||||
'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ),
|
||||
'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ),
|
||||
);
|
||||
} else {
|
||||
$remaining[ $k ] = $clause;
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $our_clauses ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
|
||||
$remaining['relation'] = $raw_meta_query['relation'];
|
||||
}
|
||||
|
||||
$query->set( 'meta_query', $remaining );
|
||||
$query->set( $this->query_var(), $our_clauses );
|
||||
}
|
||||
|
||||
// ── Override posts_join for KV-table: one JOIN alias per condition ─────
|
||||
|
||||
/**
|
||||
* Appends LEFT JOINs for each KV-table meta_query condition.
|
||||
*
|
||||
* @param string $join Current JOIN SQL.
|
||||
* @param \WP_Query $query The WP_Query object.
|
||||
* @return string Modified JOIN SQL.
|
||||
*/
|
||||
public function posts_join( ?string $join, \WP_Query $query ): string {
|
||||
$join = (string) ( $join ?? '' );
|
||||
$clauses = $this->get_clauses( $query );
|
||||
if ( empty( $clauses ) ) {
|
||||
return $join;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = TMDO_DB::table( $this->get_table() );
|
||||
$join_col = esc_sql( $this->get_join_column() );
|
||||
|
||||
foreach ( $clauses as $i => $clause ) {
|
||||
$alias = $this->alias_for( $i );
|
||||
$meta_key = $clause['meta_key'];
|
||||
|
||||
if ( false === strpos( $join, "`{$alias}`" ) ) {
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table from TMDO_DB::table(); $alias and $join_col are sanitize_key()-validated.
|
||||
$join .= $wpdb->prepare(
|
||||
" LEFT JOIN `{$table}` AS `{$alias}`"
|
||||
. " ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`{$join_col}`"
|
||||
. " AND `{$alias}`.`meta_key` = %s)",
|
||||
$meta_key
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
}
|
||||
|
||||
return $join;
|
||||
}
|
||||
|
||||
// ── Override posts_where for KV-table ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Appends WHERE conditions for KV-table meta_value comparisons.
|
||||
*
|
||||
* @param string $where Current WHERE SQL.
|
||||
* @param \WP_Query $query The WP_Query object.
|
||||
* @return string Modified WHERE SQL.
|
||||
*/
|
||||
public function posts_where( ?string $where, \WP_Query $query ): string {
|
||||
$where = (string) ( $where ?? '' );
|
||||
$clauses = $this->get_clauses( $query );
|
||||
if ( empty( $clauses ) ) {
|
||||
return $where;
|
||||
}
|
||||
|
||||
foreach ( $clauses as $i => $clause ) {
|
||||
$alias = $this->alias_for( $i );
|
||||
$col = "`{$alias}`.`meta_value`";
|
||||
$compare = $this->sanitize_compare( $clause['compare'] );
|
||||
$type = $clause['type'];
|
||||
$value = $clause['value'];
|
||||
|
||||
$where .= $this->build_condition( $col, $compare, $type, $value );
|
||||
}
|
||||
|
||||
return $where;
|
||||
}
|
||||
|
||||
// ── Override posts_groupby ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensures GROUP BY is set when KV-table clauses are active.
|
||||
*
|
||||
* @param string $groupby Current GROUP BY SQL.
|
||||
* @param \WP_Query $query The WP_Query object.
|
||||
* @return string Modified GROUP BY SQL.
|
||||
*/
|
||||
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
|
||||
$groupby = (string) ( $groupby ?? '' );
|
||||
$clauses = $this->get_clauses( $query );
|
||||
if ( empty( $clauses ) ) {
|
||||
return $groupby;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
if ( '' === trim( $groupby ) ) {
|
||||
$groupby = "`{$wpdb->posts}`.`ID`";
|
||||
}
|
||||
|
||||
return $groupby;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates a unique SQL alias for a KV-table JOIN at a given index.
|
||||
*
|
||||
* @param int $index Zero-based clause index.
|
||||
* @return string SQL alias string.
|
||||
*/
|
||||
private function alias_for( int $index ): string {
|
||||
return "wpdo_lm_{$index}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a meta key belongs to HivePress (hp_ or _hp_ prefix).
|
||||
*
|
||||
* @param string $meta_key Meta key to check.
|
||||
* @return bool True if the key is an HP key.
|
||||
*/
|
||||
private function is_hp_key( string $meta_key ): bool {
|
||||
return str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' );
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* WP_Query interceptor for hp_membership post type.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* WP_Query interceptor for hp_membership post type.
|
||||
*
|
||||
* Intercepted meta keys:
|
||||
* hp_plan => plan_id (BIGINT)
|
||||
* hp_user => user_id (BIGINT)
|
||||
* hp_price => price (VARCHAR)
|
||||
* hp_order => order_id (BIGINT)
|
||||
* hp_start_date => start_date (DATETIME)
|
||||
* hp_end_date => end_date (DATETIME)
|
||||
*/
|
||||
class TMDO_Memberships_Query extends TMDO_Query_Interceptor_Base {
|
||||
|
||||
/**
|
||||
* Returns the module identifier.
|
||||
*
|
||||
* @return string Module name.
|
||||
*/
|
||||
protected function get_module(): string {
|
||||
return 'memberships';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the post types this interceptor handles.
|
||||
*
|
||||
* @return string[] Post type slugs.
|
||||
*/
|
||||
protected function get_post_types(): array {
|
||||
return array( 'hp_membership' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the custom table name.
|
||||
*
|
||||
* @return string Table name without prefix.
|
||||
*/
|
||||
protected function get_table(): string {
|
||||
return 'hpct_memberships';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the meta_key to column mapping for hpct_memberships.
|
||||
*
|
||||
* @return array<string, string> Meta key to column name map.
|
||||
*/
|
||||
protected function get_meta_key_map(): array {
|
||||
return array(
|
||||
'hp_plan' => 'plan_id',
|
||||
'hp_user' => 'user_id',
|
||||
'hp_price' => 'price',
|
||||
'hp_order' => 'order_id',
|
||||
'hp_start_date' => 'start_date',
|
||||
'hp_end_date' => 'end_date',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* WP_Query interceptor for hp_message post type.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* WP_Query interceptor for hp_message post type.
|
||||
*
|
||||
* Intercepted meta keys:
|
||||
* hp_sender => sender_id (BIGINT)
|
||||
* hp_recipient => recipient_id (BIGINT)
|
||||
* hp_listing => listing_id (BIGINT)
|
||||
* hp_read => is_read (TINYINT)
|
||||
*/
|
||||
class TMDO_Messages_Query extends TMDO_Query_Interceptor_Base {
|
||||
|
||||
/**
|
||||
* Returns the module identifier.
|
||||
*
|
||||
* @return string Module name.
|
||||
*/
|
||||
protected function get_module(): string {
|
||||
return 'messages';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the post types this interceptor handles.
|
||||
*
|
||||
* @return string[] Post type slugs.
|
||||
*/
|
||||
protected function get_post_types(): array {
|
||||
return array( 'hp_message' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the custom table name.
|
||||
*
|
||||
* @return string Table name without prefix.
|
||||
*/
|
||||
protected function get_table(): string {
|
||||
return 'hpct_messages';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the meta_key to column mapping for hpct_messages.
|
||||
*
|
||||
* @return array<string, string> Meta key to column name map.
|
||||
*/
|
||||
protected function get_meta_key_map(): array {
|
||||
return array(
|
||||
'hp_sender' => 'sender_id',
|
||||
'hp_recipient' => 'recipient_id',
|
||||
'hp_listing' => 'listing_id',
|
||||
'hp_read' => 'is_read',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* WP_Query interceptor for hp_request post type.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* WP_Query interceptor for hp_request post type.
|
||||
*
|
||||
* Intercepted meta keys:
|
||||
* hp_vendor => vendor_id (BIGINT)
|
||||
* hp_listing => listing_id (BIGINT)
|
||||
* hp_budget => budget (VARCHAR)
|
||||
*/
|
||||
class TMDO_Requests_Query extends TMDO_Query_Interceptor_Base {
|
||||
|
||||
/**
|
||||
* Returns the module identifier.
|
||||
*
|
||||
* @return string Module name.
|
||||
*/
|
||||
protected function get_module(): string {
|
||||
return 'requests';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the post types this interceptor handles.
|
||||
*
|
||||
* @return string[] Post type slugs.
|
||||
*/
|
||||
protected function get_post_types(): array {
|
||||
return array( 'hp_request' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the custom table name.
|
||||
*
|
||||
* @return string Table name without prefix.
|
||||
*/
|
||||
protected function get_table(): string {
|
||||
return 'hpct_requests';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the meta_key to column mapping for hpct_requests.
|
||||
*
|
||||
* @return array<string, string> Meta key to column name map.
|
||||
*/
|
||||
protected function get_meta_key_map(): array {
|
||||
return array(
|
||||
'hp_vendor' => 'vendor_id',
|
||||
'hp_listing' => 'listing_id',
|
||||
'hp_budget' => 'budget',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* WP_Query interceptor for hp_review post type.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* WP_Query interceptor for hp_review post type.
|
||||
*
|
||||
* Rewrites meta_query conditions for known review meta keys to query
|
||||
* hpct_reviews directly, eliminating wp_postmeta JOIN for review queries.
|
||||
*
|
||||
* Intercepted meta keys:
|
||||
* hp_listing => listing_id (BIGINT)
|
||||
* _hp_vendor => vendor_id (BIGINT)
|
||||
* hp_rating => rating (TINYINT)
|
||||
*/
|
||||
class TMDO_Reviews_Query extends TMDO_Query_Interceptor_Base {
|
||||
|
||||
/**
|
||||
* Returns the module identifier.
|
||||
*
|
||||
* @return string Module name.
|
||||
*/
|
||||
protected function get_module(): string {
|
||||
return 'reviews';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the post types this interceptor handles.
|
||||
*
|
||||
* @return string[] Post type slugs.
|
||||
*/
|
||||
protected function get_post_types(): array {
|
||||
return array( 'hp_review' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the custom table name.
|
||||
*
|
||||
* @return string Table name without prefix.
|
||||
*/
|
||||
protected function get_table(): string {
|
||||
return 'hpct_reviews';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the meta_key to column mapping for hpct_reviews.
|
||||
*
|
||||
* @return array<string, string> Meta key to column name map.
|
||||
*/
|
||||
protected function get_meta_key_map(): array {
|
||||
return array(
|
||||
'hp_listing' => 'listing_id',
|
||||
'_hp_vendor' => 'vendor_id',
|
||||
'hp_rating' => 'rating',
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -24,6 +24,12 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) {
|
||||
}
|
||||
require_once $tmdo_core_bootstrap;
|
||||
|
||||
if ( ! function_exists( 'did_action' ) ) {
|
||||
function did_action( string $hook ): int {
|
||||
return (int) ( $GLOBALS['_wp_did_action'][ $hook ] ?? 0 );
|
||||
}
|
||||
}
|
||||
|
||||
define( 'TMDO_HIVEPRESS_PATH', dirname( __DIR__ ) . '/' );
|
||||
define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' );
|
||||
define( 'TMDO_HIVEPRESS_FILE', dirname( __DIR__ ) . '/2meet-data-optimizer-hivepress-addon.php' );
|
||||
@@ -38,7 +44,6 @@ $tmdo_hp_files = array(
|
||||
'includes/hivepress/class-tmdo-hivepress-detector.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-conflict-guard.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-bootstrap.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-comment-router.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php',
|
||||
@@ -46,7 +51,7 @@ $tmdo_hp_files = array(
|
||||
'includes/hivepress/class-tmdo-hivepress-rest.php',
|
||||
'includes/class-tmdo-hivepress-transient-filter.php',
|
||||
'includes/class-tmdo-hivepress-term-comment-fields.php',
|
||||
'includes/class-tmdo-hpct-import.php',
|
||||
'includes/class-tmdo-hivepress-entity-groups.php',
|
||||
'includes/class-tmdo-listing-stats.php',
|
||||
'admin/class-tmdo-admin-hivepress.php',
|
||||
'cli/class-tmdo-cli-hivepress.php',
|
||||
|
||||
@@ -12,147 +12,17 @@ use PHPUnit\Framework\TestCase;
|
||||
* Exercises against real MariaDB (via tests/integration/bootstrap.php) the
|
||||
* scenarios that unit tests can only stub:
|
||||
*
|
||||
* - Shadow table CREATE actually works with the declared expected_columns
|
||||
* - UNIQUE constraint on (user_id, listing_id) really prevents dup favorites
|
||||
* - mirror_insert() roundtrips through real $wpdb (SQL syntax valid)
|
||||
* - 7-state FSM lifecycle moves WPDO_Feature_Flags correctly
|
||||
*
|
||||
* @group integration
|
||||
*/
|
||||
class HivepressIntegrationTest extends TestCase {
|
||||
|
||||
/** Test-scope shadow table for favorites. */
|
||||
private const TABLE_FAVORITE = 'wp_itest_wpdo_comment_hp_favorite';
|
||||
|
||||
/** Test-scope shadow table for messages. */
|
||||
private const TABLE_MESSAGE = 'wp_itest_wpdo_comment_hp_message';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
// HivePress integration files are loaded at file-top level so the
|
||||
// Testable* subclass definitions below can resolve their parents at
|
||||
// parse time. Schema setup happens here.
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_FAVORITE . '`' );
|
||||
$wpdb->query( "CREATE TABLE `" . self::TABLE_FAVORITE . "` (
|
||||
comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
listing_id BIGINT UNSIGNED NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
UNIQUE KEY unique_user_listing (user_id, listing_id),
|
||||
KEY idx_listing (listing_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" );
|
||||
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_MESSAGE . '`' );
|
||||
$wpdb->query( "CREATE TABLE `" . self::TABLE_MESSAGE . "` (
|
||||
comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
sender_id BIGINT UNSIGNED NOT NULL,
|
||||
recipient_id BIGINT UNSIGNED NOT NULL,
|
||||
listing_id BIGINT UNSIGNED NOT NULL,
|
||||
is_read TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sent_at DATETIME NOT NULL,
|
||||
KEY idx_recipient_unread (recipient_id, is_read),
|
||||
KEY idx_thread (listing_id, sent_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" );
|
||||
}
|
||||
|
||||
public static function tearDownAfterClass(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_FAVORITE . '`' );
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_MESSAGE . '`' );
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE_FAVORITE . '`' );
|
||||
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE_MESSAGE . '`' );
|
||||
// Clear FSM module state between tests.
|
||||
$GLOBALS['_wp_options']['wpdo_features'] = array();
|
||||
}
|
||||
|
||||
// ── Schema tests ────────────────────────────────────────────────────────
|
||||
|
||||
public function test_favorite_table_has_unique_user_listing_constraint(): void {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row( 'SHOW CREATE TABLE `' . self::TABLE_FAVORITE . '`', 'ARRAY_A' );
|
||||
$this->assertNotNull( $row );
|
||||
$ddl = $row['Create Table'] ?? '';
|
||||
$this->assertStringContainsString( 'UNIQUE KEY', $ddl );
|
||||
$this->assertStringContainsString( 'unique_user_listing', $ddl );
|
||||
}
|
||||
|
||||
public function test_message_table_has_recipient_unread_index(): void {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row( 'SHOW CREATE TABLE `' . self::TABLE_MESSAGE . '`', 'ARRAY_A' );
|
||||
$this->assertNotNull( $row );
|
||||
$ddl = $row['Create Table'] ?? '';
|
||||
$this->assertStringContainsString( 'idx_recipient_unread', $ddl );
|
||||
$this->assertStringContainsString( 'recipient_id', $ddl );
|
||||
}
|
||||
|
||||
// ── mirror_insert real roundtrip ────────────────────────────────────────
|
||||
|
||||
public function test_favorite_mirror_insert_writes_real_row(): void {
|
||||
global $wpdb;
|
||||
$adapter = new WPDO_HivePress_Favorites_Adapter();
|
||||
|
||||
$comment = new stdClass();
|
||||
$comment->comment_type = 'hp_favorite';
|
||||
$comment->user_id = 11;
|
||||
$comment->comment_post_ID = 222;
|
||||
$comment->comment_date = '2026-05-04 12:00:00';
|
||||
|
||||
$adapter->mirror_insert( 1001, $comment );
|
||||
|
||||
$rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_FAVORITE . '`' );
|
||||
$this->assertSame( 1, $rows );
|
||||
|
||||
$saved = $wpdb->get_row( 'SELECT * FROM `' . self::TABLE_FAVORITE . '` WHERE comment_id = 1001', 'ARRAY_A' );
|
||||
$this->assertSame( '11', (string) $saved['user_id'] );
|
||||
$this->assertSame( '222', (string) $saved['listing_id'] );
|
||||
}
|
||||
|
||||
public function test_favorite_unique_constraint_blocks_double_tap(): void {
|
||||
global $wpdb;
|
||||
$adapter = new WPDO_HivePress_Favorites_Adapter();
|
||||
|
||||
// Two different comment_ids but same user+listing — UNIQUE should win.
|
||||
$comment = new stdClass();
|
||||
$comment->comment_type = 'hp_favorite';
|
||||
$comment->user_id = 7;
|
||||
$comment->comment_post_ID = 99;
|
||||
$comment->comment_date = '2026-05-04 12:00:00';
|
||||
|
||||
$adapter->mirror_insert( 2001, $comment );
|
||||
$adapter->mirror_insert( 2002, $comment ); // Race-style duplicate.
|
||||
|
||||
$rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_FAVORITE . '` WHERE user_id = 7 AND listing_id = 99' );
|
||||
$this->assertSame( 1, $rows, 'UNIQUE(user_id, listing_id) must reject duplicate.' );
|
||||
}
|
||||
|
||||
public function test_message_mirror_insert_promotes_recipient_id(): void {
|
||||
global $wpdb;
|
||||
$adapter = new WPDO_HivePress_Messages_Adapter();
|
||||
|
||||
$comment = new stdClass();
|
||||
$comment->comment_type = 'hp_message';
|
||||
$comment->user_id = 5; // sender
|
||||
$comment->comment_karma = 42; // recipient (HP hack)
|
||||
$comment->comment_post_ID = 99;
|
||||
$comment->comment_approved = 0;
|
||||
$comment->comment_date = '2026-05-04 12:00:00';
|
||||
|
||||
$adapter->mirror_insert( 3001, $comment );
|
||||
|
||||
$saved = $wpdb->get_row( 'SELECT * FROM `' . self::TABLE_MESSAGE . '` WHERE comment_id = 3001', 'ARRAY_A' );
|
||||
$this->assertNotNull( $saved );
|
||||
$this->assertSame( '42', (string) $saved['recipient_id'] );
|
||||
$this->assertSame( '5', (string) $saved['sender_id'] );
|
||||
|
||||
// recipient_unread index supports the dashboard query.
|
||||
$unread_count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_MESSAGE . '` WHERE recipient_id = 42 AND is_read = 0' );
|
||||
$this->assertSame( 1, $unread_count );
|
||||
}
|
||||
|
||||
// ── FSM lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
public function test_fsm_lifecycle_progression(): void {
|
||||
@@ -189,9 +59,3 @@ class HivepressIntegrationTest extends TestCase {
|
||||
$this->assertSame( 0, $count, 'Optimizer must short-circuit when module is idle.' );
|
||||
}
|
||||
}
|
||||
|
||||
// Production adapters use `$wpdb->prefix . self::TABLE` to compose the full
|
||||
// table name. Integration test bootstrap sets prefix to `wp_itest_`, so the
|
||||
// production mirror_insert() naturally writes to wp_itest_wpdo_comment_hp_*.
|
||||
// No subclassing or test-double needed — the production code path is exercised
|
||||
// directly against real MariaDB with isolated prefix.
|
||||
|
||||
@@ -44,8 +44,10 @@ class HivepressTransientFilterTest extends TestCase {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'TRUNCATE TABLE `' . self::POSTMETA . '`' );
|
||||
|
||||
// Reset wp_options state for filter tests
|
||||
// Reset wp_options / object cache state for filter tests
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
$GLOBALS['_wp_using_ext_object_cache'] = false;
|
||||
|
||||
// Default the toggle to enabled
|
||||
update_option( WPDO_Hivepress_Transient_Filter::OPT_ENABLED, '1' );
|
||||
@@ -195,6 +197,74 @@ class HivepressTransientFilterTest extends TestCase {
|
||||
$this->assertSame( array( array( 'serialized', 'data' ) ), $value );
|
||||
}
|
||||
|
||||
// ── External object cache (2.2 PR-081) ──────────────────────────────────
|
||||
|
||||
public function test_with_an_object_cache_writes_skip_wp_options(): void {
|
||||
$GLOBALS['_wp_using_ext_object_cache'] = true;
|
||||
$key = '_transient_hp_models/cat/v1';
|
||||
$translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key );
|
||||
|
||||
$this->assertTrue( WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' ) );
|
||||
WPDO_Hivepress_Transient_Filter::on_update( null, 100, '_transient_timeout_hp_models/cat/v1', '99999999', '' );
|
||||
|
||||
$this->assertFalse( get_option( '_transient_' . $translated ), 'no wp_options row' );
|
||||
$this->assertFalse( get_option( '_transient_timeout_' . $translated ), 'no wp_options row' );
|
||||
$this->assertSame( array( 'val' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) );
|
||||
$this->assertSame( array( '99999999' ), WPDO_Hivepress_Transient_Filter::on_read( null, 100, '_transient_timeout_hp_models/cat/v1', true ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries expire in the object cache too (2.3 PR-099): the value (written
|
||||
* first, before its timeout is known) after a day, the timeout after the
|
||||
* later of that and its own time, plus a minute. Orphans of deleted posts
|
||||
* no longer pile up.
|
||||
*/
|
||||
public function test_with_an_object_cache_entries_get_a_ttl(): void {
|
||||
// A day, HivePress's own default: at a week, 1.3M listings kept 4.7M
|
||||
// entries (1 GB) in a shared Redis and pushed other sites' keys out (2.4 PR-120).
|
||||
$this->assertSame( 86400, WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL );
|
||||
$GLOBALS['_wp_using_ext_object_cache'] = true;
|
||||
$key = '_transient_hp_models/cat/v1';
|
||||
$translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key );
|
||||
|
||||
WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' );
|
||||
WPDO_Hivepress_Transient_Filter::on_update( null, 100, '_transient_timeout_hp_models/cat/v1', (string) ( time() + 3600 ), '' );
|
||||
WPDO_Hivepress_Transient_Filter::on_update( null, 101, '_transient_timeout_hp_models/cat/v1', (string) ( time() - 5 ), '' );
|
||||
|
||||
$ttl = $GLOBALS['_wp_cache_ttl'][ WPDO_Hivepress_Transient_Filter::CACHE_GROUP ];
|
||||
$this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL, $ttl[ '_transient_' . $translated ] );
|
||||
// A timeout must outlive its value: HivePress takes a missing timeout for
|
||||
// "never expires" and would serve the stale value (review F5).
|
||||
$this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $ttl[ '_transient_timeout_' . $translated ] );
|
||||
$this->assertSame( WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $ttl[ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 101, $key ) ], 'already expired' );
|
||||
WPDO_Hivepress_Transient_Filter::on_update( null, 102, '_transient_timeout_hp_models/cat/v1', (string) ( time() + 2 * WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL ), '' );
|
||||
$this->assertEqualsWithDelta( 2 * WPDO_Hivepress_Transient_Filter::MAX_CACHE_TTL + 60, $GLOBALS['_wp_cache_ttl'][ WPDO_Hivepress_Transient_Filter::CACHE_GROUP ][ '_transient_timeout_' . WPDO_Hivepress_Transient_Filter::translate_key( 102, $key ) ], 2 );
|
||||
}
|
||||
|
||||
public function test_with_an_object_cache_a_value_left_in_wp_options_is_not_read(): void {
|
||||
// A miss must not fall back to get_option(): the option is absent after
|
||||
// cleanup and WordPress would add its name to `notoptions` for good.
|
||||
$key = '_transient_hp_models/cat/v1';
|
||||
$translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key );
|
||||
update_option( '_transient_' . $translated, 'from-before' );
|
||||
$GLOBALS['_wp_using_ext_object_cache'] = true;
|
||||
|
||||
$this->assertNull( WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) );
|
||||
}
|
||||
|
||||
public function test_with_an_object_cache_delete_leaves_wp_options_alone(): void {
|
||||
$key = '_transient_hp_models/cat/v1';
|
||||
$translated = WPDO_Hivepress_Transient_Filter::translate_key( 100, $key );
|
||||
update_option( '_transient_' . $translated, 'from-before' );
|
||||
$GLOBALS['_wp_using_ext_object_cache'] = true;
|
||||
WPDO_Hivepress_Transient_Filter::on_update( null, 100, $key, 'val', '' );
|
||||
|
||||
WPDO_Hivepress_Transient_Filter::on_delete( null, 100, $key, '', false );
|
||||
|
||||
$this->assertNull( WPDO_Hivepress_Transient_Filter::on_read( null, 100, $key, true ) );
|
||||
$this->assertSame( 'from-before', get_option( '_transient_' . $translated ), 'left for cleanup-hp-transients --options' );
|
||||
}
|
||||
|
||||
// ── count/purge legacy ──────────────────────────────────────────────────
|
||||
|
||||
public function test_count_legacy_postmeta_rows_returns_zero_when_clean(): void {
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Integration tests for WPDO_Listing_Meta_Interceptor — PR-3 R-2 + P-C1 fixes.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. R-2: Interceptor SKIPS hook registration when hpct_listing_meta is missing
|
||||
* (prevents the "wp_hpct_listing_meta doesn't exist" cascading errors that
|
||||
* forced 2meet-infocards to bypass the interceptor in production).
|
||||
* 2. P-C1: upsert_meta() uses 1 SQL round-trip via WPDO_DB::upsert().
|
||||
*
|
||||
* @covers WPDO_Listing_Meta_Interceptor
|
||||
*/
|
||||
class ListingMetaInterceptorTest extends TestCase {
|
||||
|
||||
/**
|
||||
* R-2: When hpct_listing_meta table is missing, register_hooks() must skip
|
||||
* gracefully and write a single explanatory error log entry.
|
||||
*
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function test_register_hooks_skips_when_table_missing(): void {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}hpct_listing_meta`" );
|
||||
|
||||
// Simulate HPCT_Core being present — register_hooks() only logs the skip
|
||||
// when HPCT is loaded but its table is missing (genuine inconsistency).
|
||||
if ( ! class_exists( 'HPCT_Core' ) ) {
|
||||
// phpcs:ignore Generic.Commenting.InlineComment.InvalidEndChar -- runtime stub
|
||||
eval( 'class HPCT_Core {}' ); // @phpcs:ignore
|
||||
}
|
||||
|
||||
// Ensure wpdo_errors exists for the skip log; harmless if pre-created.
|
||||
$wpdb->query(
|
||||
"CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}wpdo_errors` (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
module varchar(50) NOT NULL DEFAULT '',
|
||||
zone varchar(10) NOT NULL DEFAULT '',
|
||||
hook varchar(255) NOT NULL DEFAULT '',
|
||||
message longtext NOT NULL,
|
||||
context longtext,
|
||||
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_module (module)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
// Snapshot pre-call count of skip messages.
|
||||
$pre = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_errors` WHERE module = %s AND hook = %s",
|
||||
'listing_meta',
|
||||
'register_hooks'
|
||||
)
|
||||
);
|
||||
|
||||
$interceptor = new WPDO_Listing_Meta_Interceptor();
|
||||
$interceptor->register_hooks();
|
||||
|
||||
$post = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_errors` WHERE module = %s AND hook = %s",
|
||||
'listing_meta',
|
||||
'register_hooks'
|
||||
)
|
||||
);
|
||||
$this->assertSame( $pre + 1, $post, 'Expected exactly one new skip message after register_hooks()' );
|
||||
}
|
||||
|
||||
/**
|
||||
* P-C1: WPDO_DB::upsert() must produce an INSERT ... ON DUPLICATE KEY UPDATE
|
||||
* statement (single round-trip), and writes must succeed via the composite
|
||||
* unique key (listing_id, meta_key).
|
||||
*
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function test_upsert_uses_composite_unique_key(): void {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'hpct_listing_meta';
|
||||
|
||||
// Clean slate.
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
$wpdb->query(
|
||||
"CREATE TABLE `{$table}` (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
listing_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
meta_key varchar(255) NOT NULL DEFAULT '',
|
||||
meta_value longtext,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY ui_listing_meta (listing_id, meta_key(191))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
|
||||
// First upsert: INSERT.
|
||||
$result1 = WPDO_DB::upsert(
|
||||
$table,
|
||||
array(
|
||||
'listing_id' => 4242,
|
||||
'meta_key' => 'hp_price',
|
||||
'meta_value' => '100.00',
|
||||
),
|
||||
array( 'meta_value' ),
|
||||
array( 'listing_id', 'meta_key' ),
|
||||
array( '%d', '%s', '%s' )
|
||||
);
|
||||
$this->assertNotFalse( $result1 );
|
||||
|
||||
// Second upsert: UPDATE via the composite key.
|
||||
$result2 = WPDO_DB::upsert(
|
||||
$table,
|
||||
array(
|
||||
'listing_id' => 4242,
|
||||
'meta_key' => 'hp_price',
|
||||
'meta_value' => '250.50',
|
||||
),
|
||||
array( 'meta_value' ),
|
||||
array( 'listing_id', 'meta_key' ),
|
||||
array( '%d', '%s', '%s' )
|
||||
);
|
||||
$this->assertNotFalse( $result2 );
|
||||
|
||||
// Verify only one row exists for (4242, 'hp_price') with the latest value.
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s",
|
||||
4242,
|
||||
'hp_price'
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
$this->assertCount( 1, $rows, 'Composite UNIQUE must collapse two upserts into a single row' );
|
||||
$this->assertSame( '250.50', $rows[0]['meta_value'] );
|
||||
|
||||
// Cleanup.
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify table_exists() returns false when table missing, true after CREATE.
|
||||
*
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function test_table_exists_detection(): void {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}hpct_listing_meta`" );
|
||||
|
||||
// Use reflection to invoke private static.
|
||||
$ref = new ReflectionClass( WPDO_Listing_Meta_Interceptor::class );
|
||||
$method = $ref->getMethod( 'table_exists' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$this->assertFalse( $method->invoke( null ), 'table_exists must return false when table missing' );
|
||||
}
|
||||
}
|
||||
@@ -21,16 +21,29 @@ if ( ! file_exists( $tmdo_core_bootstrap ) ) {
|
||||
. dirname( __DIR__, 3 ) . '/2meet-data-optimizer'
|
||||
);
|
||||
}
|
||||
// Defined before the core bootstrap (which only adds it when missing) so tests can read the TTL.
|
||||
if ( ! function_exists( 'wp_cache_set' ) ) {
|
||||
function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool {
|
||||
$GLOBALS['_wp_cache'][ $group ][ $key ] = $value;
|
||||
$GLOBALS['_wp_cache_ttl'][ $group ][ $key ] = (int) $ttl;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
require_once $tmdo_core_bootstrap;
|
||||
|
||||
// External object cache switch for the transient filter tests (Redis on real sites).
|
||||
if ( ! function_exists( 'wp_using_ext_object_cache' ) ) {
|
||||
function wp_using_ext_object_cache( $using = null ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||
return ! empty( $GLOBALS['_wp_using_ext_object_cache'] );
|
||||
}
|
||||
}
|
||||
|
||||
define( 'TMDO_HIVEPRESS_PATH', dirname( __DIR__, 2 ) . '/' );
|
||||
define( 'TMDO_HIVEPRESS_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer-hivepress-addon/' );
|
||||
define( 'TMDO_HIVEPRESS_FILE', TMDO_HIVEPRESS_PATH . '2meet-data-optimizer-hivepress-addon.php' );
|
||||
define( 'TMDO_HIVEPRESS_VERSION', '0.1.0' );
|
||||
|
||||
// ── AddOn classes ──────────────────────────────────────────────────────────
|
||||
// Superset of the unit bootstrap's list: the integration suite also exercises
|
||||
// the interceptors, which the unit suite does not load.
|
||||
|
||||
$tmdo_hp_files = array(
|
||||
'includes/class-tmdo-hivepress.php',
|
||||
@@ -39,7 +52,6 @@ $tmdo_hp_files = array(
|
||||
'includes/hivepress/class-tmdo-hivepress-detector.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-conflict-guard.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-bootstrap.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-comment-router.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php',
|
||||
'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php',
|
||||
@@ -47,7 +59,7 @@ $tmdo_hp_files = array(
|
||||
'includes/hivepress/class-tmdo-hivepress-rest.php',
|
||||
'includes/class-tmdo-hivepress-transient-filter.php',
|
||||
'includes/class-tmdo-hivepress-term-comment-fields.php',
|
||||
'includes/class-tmdo-hpct-import.php',
|
||||
'includes/class-tmdo-hivepress-entity-groups.php',
|
||||
'admin/class-tmdo-admin-hivepress.php',
|
||||
'cli/class-tmdo-cli-hivepress.php',
|
||||
);
|
||||
@@ -55,12 +67,6 @@ $tmdo_hp_files = array(
|
||||
foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/hivepress/adapters/*.php' ) ?: array() as $tmdo_hp_glob ) {
|
||||
$tmdo_hp_files[] = 'includes/hivepress/adapters/' . basename( $tmdo_hp_glob );
|
||||
}
|
||||
foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/interceptors/*.php' ) ?: array() as $tmdo_hp_glob ) {
|
||||
$tmdo_hp_files[] = 'includes/interceptors/' . basename( $tmdo_hp_glob );
|
||||
}
|
||||
foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/query/*.php' ) ?: array() as $tmdo_hp_glob ) {
|
||||
$tmdo_hp_files[] = 'includes/query/' . basename( $tmdo_hp_glob );
|
||||
}
|
||||
|
||||
foreach ( $tmdo_hp_files as $tmdo_hp_file ) {
|
||||
$tmdo_hp_path = TMDO_HIVEPRESS_PATH . $tmdo_hp_file;
|
||||
|
||||
@@ -62,7 +62,7 @@ class HivePressAttributeBridgeTest extends TestCase {
|
||||
|
||||
$this->assertSame( 'listing', $record['model'] );
|
||||
$this->assertSame( 'beds', $record['slug'] );
|
||||
$this->assertSame( 'hp_listing_beds', $record['meta_key'] );
|
||||
$this->assertSame( 'hp_beds', $record['meta_key'] );
|
||||
$this->assertSame( 'integer', $record['type'] );
|
||||
$this->assertTrue( $record['filterable'] );
|
||||
$this->assertFalse( $record['sortable'] );
|
||||
|
||||
@@ -33,19 +33,6 @@ class HivePressBookingsAdapterTest extends TestCase {
|
||||
$this->assertSame( 'HivePress\\Bookings\\Plugin', $this->adapter->detection_class() );
|
||||
}
|
||||
|
||||
public function test_on_register_fields_reserves_booking_hot_columns(): void {
|
||||
$registry = WPDO_Schema_Registry::instance();
|
||||
$this->adapter->on_register_fields( $registry );
|
||||
|
||||
$start = $registry->get_field( 'hp_booking', 'hp_start_time' );
|
||||
$this->assertSame( 'hot', $start['zone'] );
|
||||
$this->assertTrue( (bool) $start['indexed'] );
|
||||
|
||||
$end = $registry->get_field( 'hp_booking', 'hp_end_time' );
|
||||
$this->assertSame( 'hot', $end['zone'] );
|
||||
$this->assertTrue( (bool) $end['indexed'] );
|
||||
}
|
||||
|
||||
public function test_score_reflects_unverified_perf_dimension(): void {
|
||||
$score = $this->adapter->suitability_score();
|
||||
// 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.
|
||||
$this->assertSame( 9.88, $score['aggregate'] );
|
||||
}
|
||||
|
||||
public function test_migrations_lists_booking_module(): void {
|
||||
$this->assertSame( array( 'hot_hp_booking' ), $this->adapter->migrations() );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,16 @@ class HivePressConflictGuardTest extends TestCase {
|
||||
$this->assertSame( '', $out );
|
||||
}
|
||||
|
||||
public function test_guidance_does_not_point_to_a_missing_command(): void {
|
||||
// There is no HPCT importer: `wp wpdo hivepress migrate-from-hpct` never
|
||||
// existed and TMDO_HPCT_Import had no caller once core dropped HPCT.
|
||||
$root = dirname( __DIR__, 2 );
|
||||
foreach ( array( 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php', 'admin/class-tmdo-admin-hivepress.php' ) as $file ) {
|
||||
$this->assertStringNotContainsString( 'migrate-from-hpct', (string) file_get_contents( $root . '/' . $file ), $file );
|
||||
}
|
||||
$this->assertFileDoesNotExist( $root . '/includes/class-tmdo-hpct-import.php' );
|
||||
}
|
||||
|
||||
public function test_memo_cache_avoids_repeat_probing(): void {
|
||||
WPDO_HivePress_Conflict_Guard::reset_for_tests();
|
||||
$first = WPDO_HivePress_Conflict_Guard::detect_conflicts();
|
||||
|
||||
@@ -46,29 +46,10 @@ class HivePressCoreAdapterTest extends TestCase {
|
||||
$this->assertSame( '1.7.0', $this->adapter->minimum_addon_version() );
|
||||
}
|
||||
|
||||
public function test_migrations_returns_listing_and_vendor_modules(): void {
|
||||
$mods = $this->adapter->migrations();
|
||||
$this->assertContains( 'hot_hp_listing', $mods );
|
||||
$this->assertContains( 'hot_hp_vendor', $mods );
|
||||
}
|
||||
|
||||
public function test_on_register_fields_populates_schema_registry(): void {
|
||||
$registry = WPDO_Schema_Registry::instance();
|
||||
$this->adapter->on_register_fields( $registry );
|
||||
|
||||
// Listing hot fields.
|
||||
$drafted = $registry->get_field( 'hp_listing', 'hp_drafted' );
|
||||
$this->assertIsArray( $drafted );
|
||||
$this->assertSame( 'hot', $drafted['zone'] );
|
||||
$this->assertTrue( (bool) $drafted['indexed'] );
|
||||
|
||||
$expired = $registry->get_field( 'hp_listing', 'hp_expired_time' );
|
||||
$this->assertSame( 'hot', $expired['zone'] );
|
||||
$this->assertStringContainsString( 'bigint', $expired['data_type'] );
|
||||
|
||||
$featured_time = $registry->get_field( 'hp_listing', 'hp_featured_time' );
|
||||
$this->assertSame( 'hot', $featured_time['zone'] );
|
||||
|
||||
// Vendor cold blob.
|
||||
$vendor_image = $registry->get_field( 'hp_vendor', 'hp_image' );
|
||||
$this->assertIsArray( $vendor_image );
|
||||
@@ -87,16 +68,6 @@ class HivePressCoreAdapterTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
public function test_doctor_check_reports_table_status(): void {
|
||||
$result = $this->adapter->doctor_check();
|
||||
$this->assertIsArray( $result );
|
||||
$this->assertArrayHasKey( 'ok', $result );
|
||||
$this->assertArrayHasKey( 'message', $result );
|
||||
$this->assertArrayHasKey( 'details', $result );
|
||||
// The stub $wpdb returns null for SHOW TABLES, so tables appear missing → ok=false.
|
||||
$this->assertFalse( $result['ok'] );
|
||||
}
|
||||
|
||||
public function test_optimize_query_extracts_hot_clauses(): void {
|
||||
$registry = WPDO_Schema_Registry::instance();
|
||||
$this->adapter->on_register_fields( $registry );
|
||||
@@ -145,4 +116,33 @@ class HivePressCoreAdapterTest extends TestCase {
|
||||
$this->adapter->optimize_search( $query, array() );
|
||||
$this->assertEmpty( $query->get( 'wpdo_hot_clauses' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the query hooks with core's post mode set to $mode.
|
||||
*
|
||||
* @return array<int, mixed> Callbacks bound to the listing query hooks.
|
||||
*/
|
||||
private function query_hooks_in_post_mode( string $mode ): array {
|
||||
$cache = new ReflectionProperty( TMDO_Mode_Manager::class, 'cache' );
|
||||
$cache->setAccessible( true );
|
||||
$cache->setValue( null, array( 'post' => $mode, 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
unset( $GLOBALS['_wp_filter_callbacks']['hivepress/v1/models/listing/query'], $GLOBALS['_wp_filter_callbacks']['hivepress/v1/models/listing/search'] );
|
||||
try {
|
||||
$this->adapter->on_register_query_hooks();
|
||||
} finally {
|
||||
$cache->setValue( null, null );
|
||||
}
|
||||
return array_merge(
|
||||
$GLOBALS['_wp_filter_callbacks']['hivepress/v1/models/listing/query'] ?? array(),
|
||||
$GLOBALS['_wp_filter_callbacks']['hivepress/v1/models/listing/search'] ?? array()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_query_hooks_are_bound_while_the_core_post_router_is_idle(): void {
|
||||
$this->assertCount( 2, $this->query_hooks_in_post_mode( 'dual_write' ) );
|
||||
}
|
||||
|
||||
public function test_query_hooks_are_left_to_the_core_post_router_when_it_is_active(): void {
|
||||
$this->assertSame( array(), $this->query_hooks_in_post_mode( 'shadow_read' ), 'two routers would claim the same clauses' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ use PHPUnit\Framework\TestCase;
|
||||
/**
|
||||
* Tests for WPDO_HivePress_Favorites_Adapter.
|
||||
*
|
||||
* The adapter no longer mirrors favorites into a shadow table: that table was
|
||||
* never created, so every mirror write failed.
|
||||
*
|
||||
* @covers WPDO_HivePress_Favorites_Adapter
|
||||
*/
|
||||
class HivePressFavoritesAdapterTest extends TestCase {
|
||||
@@ -32,72 +35,22 @@ class HivePressFavoritesAdapterTest extends TestCase {
|
||||
|
||||
public function test_constants_exposed(): void {
|
||||
$this->assertSame( 'hp_favorite', WPDO_HivePress_Favorites_Adapter::COMMENT_TYPE );
|
||||
$this->assertSame( 'wpdo_comment_hp_favorite', WPDO_HivePress_Favorites_Adapter::TABLE );
|
||||
}
|
||||
|
||||
public function test_on_register_custom_tables_declares_shadow_table(): void {
|
||||
public function test_registers_no_shadow_table(): void {
|
||||
$registry = WPDO_Custom_Table_Registry::instance();
|
||||
$this->adapter->on_register_custom_tables( $registry );
|
||||
|
||||
$tables = $registry->all();
|
||||
$this->assertCount( 1, $tables );
|
||||
|
||||
$cfg = reset( $tables );
|
||||
$this->assertSame( 'hivepress-favorites', $cfg['provider'] );
|
||||
$this->assertSame( 'wpdo_comment_hp_favorite', $cfg['table_name'] );
|
||||
$this->assertSame( 'comment_id', $cfg['primary_key'] );
|
||||
$this->assertArrayHasKey( 'unique_user_listing', $cfg['indexes'] );
|
||||
$this->assertStringContainsString( 'UNIQUE', $cfg['indexes']['unique_user_listing'] );
|
||||
$this->assertSame( array(), $registry->all() );
|
||||
}
|
||||
|
||||
public function test_mirror_insert_skips_non_favorite_comments(): void {
|
||||
// Stub $wpdb tracks queries.
|
||||
global $wpdb;
|
||||
$wpdb->queries = array();
|
||||
public function test_binds_no_comment_hooks(): void {
|
||||
$GLOBALS['_wp_filter_callbacks'] = array();
|
||||
|
||||
$comment = new stdClass();
|
||||
$comment->comment_type = 'comment'; // ← not hp_favorite
|
||||
$comment->user_id = 1;
|
||||
$comment->comment_post_ID = 99;
|
||||
$this->adapter->on_register_event_hooks();
|
||||
|
||||
$this->adapter->mirror_insert( 123, $comment );
|
||||
$this->assertSame( array(), $wpdb->queries );
|
||||
}
|
||||
|
||||
public function test_mirror_insert_writes_for_hp_favorite(): void {
|
||||
global $wpdb;
|
||||
$wpdb->queries = array();
|
||||
|
||||
$comment = new stdClass();
|
||||
$comment->comment_type = 'hp_favorite';
|
||||
$comment->user_id = 7;
|
||||
$comment->comment_post_ID = 42;
|
||||
$comment->comment_date = '2026-05-04 12:00:00';
|
||||
|
||||
$this->adapter->mirror_insert( 100, $comment );
|
||||
|
||||
$this->assertCount( 1, $wpdb->queries );
|
||||
$this->assertStringContainsString( 'INSERT IGNORE', $wpdb->queries[0] );
|
||||
$this->assertStringContainsString( 'wpdo_comment_hp_favorite', $wpdb->queries[0] );
|
||||
}
|
||||
|
||||
public function test_mirror_insert_skips_when_user_or_listing_missing(): void {
|
||||
global $wpdb;
|
||||
$wpdb->queries = array();
|
||||
|
||||
$comment = new stdClass();
|
||||
$comment->comment_type = 'hp_favorite';
|
||||
$comment->user_id = 0;
|
||||
$comment->comment_post_ID = 99;
|
||||
|
||||
$this->adapter->mirror_insert( 100, $comment );
|
||||
$this->assertSame( array(), $wpdb->queries );
|
||||
}
|
||||
|
||||
public function test_doctor_check_reports_missing_table_in_stub_env(): void {
|
||||
$result = $this->adapter->doctor_check();
|
||||
$this->assertFalse( $result['ok'] );
|
||||
$this->assertStringContainsString( 'wpdo_comment_hp_favorite', $result['message'] );
|
||||
$this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
|
||||
$this->assertArrayNotHasKey( 'deleted_comment', $GLOBALS['_wp_filter_callbacks'] );
|
||||
}
|
||||
|
||||
public function test_score_aggregates_to_perfect_ten(): void {
|
||||
@@ -105,7 +58,7 @@ class HivePressFavoritesAdapterTest extends TestCase {
|
||||
$this->assertSame( 10.0, $score['aggregate'] );
|
||||
}
|
||||
|
||||
public function test_migrations_lists_comment_module(): void {
|
||||
$this->assertSame( array( 'comment_hp_favorite' ), $this->adapter->migrations() );
|
||||
public function test_migrations_is_empty(): void {
|
||||
$this->assertSame( array(), $this->adapter->migrations() );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The AddOn owns the HivePress entity groups core used to register.
|
||||
*
|
||||
* Their definitions are persisted (wpdo_registry_meta schema hash) and the
|
||||
* flat tables on live sites were built from them, so they are pinned to the
|
||||
* persisted hashes: any drift would ALTER flat tables that may hold
|
||||
* aeav_only data. (Values recorded on dev20, 2026-09-25.)
|
||||
*/
|
||||
class HivePressEntityGroupsTest extends TestCase {
|
||||
|
||||
private const PERSISTED_HASHES = array(
|
||||
'post/hp_listing_core' => 'fb5ab760',
|
||||
'post/hp_request_core' => '27d6f4f5',
|
||||
'post/hp_vendor_core' => '5366e5c8',
|
||||
'user/hp_user' => '61ac526f',
|
||||
);
|
||||
|
||||
/**
|
||||
* Lookups that do not pass a post type (or run with scoped routing off,
|
||||
* until a site's rows are reconciled) keep the legacy owner of keys shared
|
||||
* by several post groups: the LAST group registered. Pinned so the
|
||||
* registration order cannot change silently.
|
||||
*/
|
||||
private const SHARED_KEY_OWNERS = array(
|
||||
'hp_status' => 'hp_request_core',
|
||||
'hp_expired_time' => 'hp_request_core',
|
||||
'hp_view_count' => 'hp_request_core',
|
||||
'hp_verified' => 'hp_vendor_core',
|
||||
'hp_rating' => 'hp_vendor_core',
|
||||
'hp_rating_count' => 'hp_vendor_core',
|
||||
'hp_hourly_rate' => 'hp_vendor_core',
|
||||
'hp_user' => 'hp_vendor_core',
|
||||
);
|
||||
|
||||
private function post_adapter(): TMDO_Entity_Adapter_Interface {
|
||||
$adapter = $this->createMock( TMDO_Entity_Adapter_Interface::class );
|
||||
$adapter->method( 'get_entity_id_column' )->willReturn( 'post_id' );
|
||||
return $adapter;
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
TMDO_Entity_Registry::init();
|
||||
TMDO_Entity_Registry::register_adapter( 'post', $this->post_adapter() );
|
||||
TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||
TMDO_HivePress_Entity_Groups::register_entity_fields();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
unset( $GLOBALS['_wp_options'][ TMDO_Entity_Registry::SCOPED_ROUTING_OPTION ] );
|
||||
unset( $GLOBALS['_wp_filter_callbacks']['tmdo_rest_default_post_type'] );
|
||||
}
|
||||
|
||||
public function test_definitions_match_persisted_schema_hashes(): void {
|
||||
foreach ( self::PERSISTED_HASHES as $id => $hash ) {
|
||||
list( $type, $group ) = explode( '/', $id );
|
||||
$fields = TMDO_Entity_Registry::get_group_fields( $type, $group );
|
||||
$this->assertNotEmpty( $fields, "{$id} not registered" );
|
||||
$this->assertSame( $hash, substr( TMDO_Schema_Manager::calculate_schema_hash( $fields ), 0, 8 ), "{$id} drifted from the persisted definition" );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress filters and sorts listings by these; they are declared as
|
||||
* group indexes (added in place by core) rather than `searchable`, which
|
||||
* would change the pinned hash and rebuild the table.
|
||||
*/
|
||||
public function test_listing_columns_filtered_and_sorted_on_are_indexed(): void {
|
||||
$this->assertSame(
|
||||
array(
|
||||
'hourly_rate' => array( 'hp_hourly_rate' ),
|
||||
'rating_count' => array( 'hp_rating_count' ),
|
||||
// Sorts: the value then the ID tie-break, read in order (2.4 PR-119).
|
||||
'price_id' => array( 'hp_price', 'post_id' ),
|
||||
'rating_id' => array( 'hp_rating', 'post_id' ),
|
||||
'hourly_rate_id' => array( 'hp_hourly_rate', 'post_id' ),
|
||||
),
|
||||
TMDO_Entity_Registry::get_group_indexes( 'post', 'hp_listing_core' )
|
||||
);
|
||||
}
|
||||
|
||||
public function test_site_listing_attributes_are_opt_in(): void {
|
||||
$this->assertSame( array(), TMDO_Entity_Registry::get_group_fields( 'post', 'hp_listing_attrs' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* A site flattens its own HivePress listing attributes (e.g. a "Warranty"
|
||||
* number attribute → hp_warranty) in a group of their own: a new table,
|
||||
* indexed at creation; hp_listing_core and its pinned hash stay as they are.
|
||||
*/
|
||||
public function test_site_listing_attributes_get_their_own_indexed_group(): void {
|
||||
$fields = static fn() => array(
|
||||
'hp_warranty' => 'integer',
|
||||
'hp_price' => 'decimal', // Already in hp_listing_core.
|
||||
'warranty' => 'integer', // Not a HivePress key.
|
||||
'hp_color' => 'colour', // Unknown type.
|
||||
'hp_features' => 'json', // No plain index on LONGTEXT.
|
||||
);
|
||||
add_filter( 'tmdo_hp_listing_attribute_fields', $fields );
|
||||
$GLOBALS['_wp_did_action']['after_setup_theme'] = 1;
|
||||
try {
|
||||
TMDO_Entity_Registry::init();
|
||||
TMDO_Entity_Registry::register_adapter( 'post', $this->post_adapter() );
|
||||
TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||
TMDO_HivePress_Entity_Groups::register_entity_fields();
|
||||
} finally {
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_hp_listing_attribute_fields'] = array();
|
||||
unset( $GLOBALS['_wp_did_action'] );
|
||||
}
|
||||
|
||||
$this->assertSame( array( 'hp_warranty' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ) );
|
||||
$this->assertTrue( (bool) TMDO_Entity_Registry::get_group_fields( 'post', 'hp_listing_attrs' )[0]['searchable'] );
|
||||
$this->assertSame( array( 'hp_listing' ), TMDO_Entity_Registry::get_group_post_types( 'post', 'hp_listing_attrs' ) );
|
||||
$this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_price' )['group'] );
|
||||
$this->test_definitions_match_persisted_schema_hashes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration runs twice (plugins_loaded, then init:1): the group is
|
||||
* registered on the run after themes load, so a theme's filter counts and
|
||||
* a filter that changed between runs cannot collide (review F8).
|
||||
*/
|
||||
public function test_site_listing_attributes_are_registered_once_themes_are_loaded(): void {
|
||||
add_filter( 'tmdo_hp_listing_attribute_fields', static fn() => array( 'hp_warranty' => 'integer' ) );
|
||||
try {
|
||||
TMDO_Entity_Registry::init();
|
||||
TMDO_Entity_Registry::register_adapter( 'post', $this->post_adapter() );
|
||||
TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||
TMDO_HivePress_Entity_Groups::register_entity_fields();
|
||||
$this->assertSame( array(), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ), 'plugins_loaded run: not yet' );
|
||||
|
||||
$GLOBALS['_wp_did_action']['after_setup_theme'] = 1;
|
||||
TMDO_HivePress_Entity_Groups::register_entity_fields();
|
||||
} finally {
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_hp_listing_attribute_fields'] = array();
|
||||
unset( $GLOBALS['_wp_did_action'] );
|
||||
}
|
||||
|
||||
$this->assertSame( array( 'hp_warranty' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_attrs' ) );
|
||||
$this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
|
||||
}
|
||||
|
||||
public function test_shared_keys_keep_their_current_owner(): void {
|
||||
foreach ( self::SHARED_KEY_OWNERS as $key => $group ) {
|
||||
$this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key )['group'], $key );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_shared_keys_follow_the_post_type_when_scoped_routing_is_on(): void {
|
||||
$GLOBALS['_wp_options'][ TMDO_Entity_Registry::SCOPED_ROUTING_OPTION ] = '1';
|
||||
|
||||
foreach ( array( 'hp_listing' => 'hp_listing_core', 'hp_request' => 'hp_request_core', 'hp_vendor' => 'hp_vendor_core' ) as $post_type => $group ) {
|
||||
foreach ( TMDO_Entity_Registry::get_group_keys( 'post', $group ) as $key ) {
|
||||
$this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key, $post_type )['group'], "{$post_type}:{$key}" );
|
||||
}
|
||||
}
|
||||
$this->assertNull( TMDO_Entity_Registry::get_field( 'post', 'hp_rating', 'post' ), 'HivePress keys stay native on other post types' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields only the Hot zone used to cover (ADR-003), as their own groups.
|
||||
*/
|
||||
private const HOT_ONLY_GROUPS = array(
|
||||
'hp_draft_flags' => array( array( 'hp_listing', 'hp_request' ), array( 'hp_drafted' ) ),
|
||||
'hp_booking_core' => array( array( 'hp_booking' ), array( 'hp_start_time', 'hp_end_time', 'hp_quantity' ) ),
|
||||
'hp_membership_plan_core' => array( array( 'hp_membership_plan' ), array( 'hp_expire_period', 'hp_primary' ) ),
|
||||
'hp_membership_core' => array( array( 'hp_membership' ), array( 'hp_expired_time' ) ),
|
||||
);
|
||||
|
||||
public function test_hot_only_fields_have_entity_groups_scoped_to_their_post_types(): void {
|
||||
foreach ( self::HOT_ONLY_GROUPS as $group => list( $post_types, $keys ) ) {
|
||||
$this->assertSame( $post_types, TMDO_Entity_Registry::get_group_post_types( 'post', $group ), $group );
|
||||
$this->assertSame( $keys, TMDO_Entity_Registry::get_group_keys( 'post', $group ), $group );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_hot_only_fields_route_to_their_group_when_scoped_routing_is_on(): void {
|
||||
$GLOBALS['_wp_options'][ TMDO_Entity_Registry::SCOPED_ROUTING_OPTION ] = '1';
|
||||
|
||||
foreach ( self::HOT_ONLY_GROUPS as $group => list( $post_types, $keys ) ) {
|
||||
foreach ( $post_types as $post_type ) {
|
||||
foreach ( $keys as $key ) {
|
||||
$this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key, $post_type )['group'], "{$post_type}:{$key}" );
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_expired_time', 'hp_listing' )['group'] );
|
||||
}
|
||||
|
||||
public function test_hot_only_groups_add_no_collisions(): void {
|
||||
$this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
|
||||
}
|
||||
|
||||
public function test_reconciler_plans_the_misrouted_hivepress_keys(): void {
|
||||
$moves = array();
|
||||
foreach ( TMDO_Post_Group_Reconciler::plan() as $m ) {
|
||||
$moves[] = "{$m['source']}>{$m['target']}:{$m['key']}";
|
||||
}
|
||||
sort( $moves );
|
||||
|
||||
$this->assertSame(
|
||||
array(
|
||||
'hp_request_core>hp_listing_core:hp_expired_time',
|
||||
'hp_request_core>hp_listing_core:hp_status',
|
||||
'hp_request_core>hp_listing_core:hp_view_count',
|
||||
'hp_request_core>hp_membership_core:hp_expired_time',
|
||||
'hp_vendor_core>hp_listing_core:hp_hourly_rate',
|
||||
'hp_vendor_core>hp_listing_core:hp_rating',
|
||||
'hp_vendor_core>hp_listing_core:hp_rating_count',
|
||||
'hp_vendor_core>hp_listing_core:hp_verified',
|
||||
'hp_vendor_core>hp_request_core:hp_user',
|
||||
),
|
||||
$moves
|
||||
);
|
||||
}
|
||||
|
||||
public function test_rest_listings_default_to_hivepress_listings(): void {
|
||||
TMDO_HivePress_Entity_Groups::register();
|
||||
|
||||
$this->assertSame( 'hp_listing', TMDO_REST_API::default_post_type() );
|
||||
}
|
||||
|
||||
public function test_core_adapter_does_not_register_a_conflicting_group(): void {
|
||||
( new TMDO_HivePress_Core_Adapter() )->on_register_entity_fields();
|
||||
|
||||
$this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
|
||||
}
|
||||
}
|
||||
@@ -33,19 +33,6 @@ class HivePressMarketplaceAdapterTest extends TestCase {
|
||||
$this->assertSame( 'HivePress\\Marketplace\\Plugin', $this->adapter->detection_class() );
|
||||
}
|
||||
|
||||
public function test_on_register_fields_registers_hot_price_and_warm_counter(): void {
|
||||
$registry = WPDO_Schema_Registry::instance();
|
||||
$this->adapter->on_register_fields( $registry );
|
||||
|
||||
$price = $registry->get_field( 'hp_listing', 'hp_purchase_price' );
|
||||
$this->assertSame( 'hot', $price['zone'] );
|
||||
$this->assertTrue( (bool) $price['indexed'] );
|
||||
$this->assertStringContainsString( 'decimal', $price['data_type'] );
|
||||
|
||||
$count = $registry->get_field( 'hp_listing', 'hp_purchase_count' );
|
||||
$this->assertSame( 'warm', $count['zone'] );
|
||||
}
|
||||
|
||||
public function test_score_aggregates_to_nine_eight_eight(): void {
|
||||
// D8 = 0.9 → aggregate = (7×1.0 + 0.9)/8 × 10 = 9.875 → 9.88.
|
||||
$this->assertSame( 9.88, $this->adapter->suitability_score()['aggregate'] );
|
||||
|
||||
@@ -33,41 +33,8 @@ class HivePressMembershipsAdapterTest extends TestCase {
|
||||
$this->assertSame( '2.0.0', $this->adapter->minimum_addon_version() );
|
||||
}
|
||||
|
||||
public function test_on_register_fields_registers_three_hot_fields(): void {
|
||||
$registry = WPDO_Schema_Registry::instance();
|
||||
$this->adapter->on_register_fields( $registry );
|
||||
|
||||
// Plan fields.
|
||||
$expire_period = $registry->get_field( 'hp_membership_plan', 'hp_expire_period' );
|
||||
$this->assertSame( 'hot', $expire_period['zone'] );
|
||||
$this->assertStringContainsString( 'int', $expire_period['data_type'] );
|
||||
|
||||
$primary = $registry->get_field( 'hp_membership_plan', 'hp_primary' );
|
||||
$this->assertSame( 'hot', $primary['zone'] );
|
||||
$this->assertTrue( (bool) $primary['indexed'] );
|
||||
|
||||
// Membership field — the hottest path.
|
||||
$expired = $registry->get_field( 'hp_membership', 'hp_expired_time' );
|
||||
$this->assertSame( 'hot', $expired['zone'] );
|
||||
$this->assertTrue( (bool) $expired['indexed'] );
|
||||
$this->assertStringContainsString( 'bigint', $expired['data_type'] );
|
||||
}
|
||||
|
||||
public function test_doctor_check_reports_missing_tables_in_stub_env(): void {
|
||||
$result = $this->adapter->doctor_check();
|
||||
$this->assertFalse( $result['ok'] );
|
||||
$this->assertArrayHasKey( 'hp_membership_plan', $result['details'] );
|
||||
$this->assertArrayHasKey( 'hp_membership', $result['details'] );
|
||||
}
|
||||
|
||||
public function test_score_aggregates_to_perfect_ten(): void {
|
||||
$score = $this->adapter->suitability_score();
|
||||
$this->assertSame( 10.0, $score['aggregate'] );
|
||||
}
|
||||
|
||||
public function test_migrations_lists_both_hot_modules(): void {
|
||||
$mods = $this->adapter->migrations();
|
||||
$this->assertContains( 'hot_hp_membership_plan', $mods );
|
||||
$this->assertContains( 'hot_hp_membership', $mods );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ use PHPUnit\Framework\TestCase;
|
||||
/**
|
||||
* Tests for WPDO_HivePress_Messages_Adapter.
|
||||
*
|
||||
* The adapter no longer mirrors messages into a shadow table: that table was
|
||||
* never created, so every mirror write failed.
|
||||
*
|
||||
* @covers WPDO_HivePress_Messages_Adapter
|
||||
*/
|
||||
class HivePressMessagesAdapterTest extends TestCase {
|
||||
@@ -28,77 +31,26 @@ class HivePressMessagesAdapterTest extends TestCase {
|
||||
$this->assertSame( 'hivepress-messages', $this->adapter->plugin_slug() );
|
||||
$this->assertSame( 'HivePress\\Messages\\Plugin', $this->adapter->detection_class() );
|
||||
$this->assertSame( 'hp_message', WPDO_HivePress_Messages_Adapter::COMMENT_TYPE );
|
||||
$this->assertSame( 'wpdo_comment_hp_message', WPDO_HivePress_Messages_Adapter::TABLE );
|
||||
}
|
||||
|
||||
public function test_on_register_custom_tables_declares_indexed_recipient(): void {
|
||||
public function test_registers_no_shadow_table(): void {
|
||||
$registry = WPDO_Custom_Table_Registry::instance();
|
||||
$this->adapter->on_register_custom_tables( $registry );
|
||||
|
||||
$tables = $registry->all();
|
||||
$cfg = reset( $tables );
|
||||
$this->assertSame( 'wpdo_comment_hp_message', $cfg['table_name'] );
|
||||
$this->assertArrayHasKey( 'recipient_id', $cfg['expected_columns'] );
|
||||
$this->assertArrayHasKey( 'idx_recipient_unread', $cfg['indexes'] );
|
||||
$this->assertStringContainsString( 'recipient_id, is_read', $cfg['indexes']['idx_recipient_unread'] );
|
||||
$this->assertSame( array(), $registry->all() );
|
||||
}
|
||||
|
||||
public function test_mirror_insert_skips_non_message_comments(): void {
|
||||
global $wpdb;
|
||||
$wpdb->queries = array();
|
||||
public function test_binds_no_comment_hooks(): void {
|
||||
$GLOBALS['_wp_filter_callbacks'] = array();
|
||||
|
||||
$comment = new stdClass();
|
||||
$comment->comment_type = 'comment';
|
||||
$comment->user_id = 1;
|
||||
$comment->comment_karma = 5;
|
||||
$this->adapter->on_register_event_hooks();
|
||||
|
||||
$this->adapter->mirror_insert( 1, $comment );
|
||||
$this->assertSame( array(), $wpdb->queries );
|
||||
$this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
|
||||
$this->assertArrayNotHasKey( 'wp_set_comment_status', $GLOBALS['_wp_filter_callbacks'] );
|
||||
$this->assertArrayNotHasKey( 'deleted_comment', $GLOBALS['_wp_filter_callbacks'] );
|
||||
}
|
||||
|
||||
public function test_mirror_insert_promotes_recipient_from_comment_karma(): void {
|
||||
global $wpdb;
|
||||
$wpdb->queries = array();
|
||||
|
||||
$comment = new stdClass();
|
||||
$comment->comment_type = 'hp_message';
|
||||
$comment->user_id = 7; // sender
|
||||
$comment->comment_karma = 42; // ← recipient hack lives here
|
||||
$comment->comment_post_ID = 99;
|
||||
$comment->comment_approved = 0;
|
||||
$comment->comment_date = '2026-05-04 12:00:00';
|
||||
|
||||
$this->adapter->mirror_insert( 100, $comment );
|
||||
|
||||
$this->assertCount( 1, $wpdb->queries );
|
||||
$sql = $wpdb->queries[0];
|
||||
$this->assertStringContainsString( 'INSERT INTO', $sql );
|
||||
$this->assertStringContainsString( 'wpdo_comment_hp_message', $sql );
|
||||
// The promoted recipient_id should appear in the bound SQL.
|
||||
$this->assertStringContainsString( '42', $sql );
|
||||
}
|
||||
|
||||
public function test_mirror_insert_skips_when_sender_or_recipient_zero(): void {
|
||||
global $wpdb;
|
||||
$wpdb->queries = array();
|
||||
|
||||
$comment = new stdClass();
|
||||
$comment->comment_type = 'hp_message';
|
||||
$comment->user_id = 0; // bad sender
|
||||
$comment->comment_karma = 42;
|
||||
$comment->comment_post_ID = 99;
|
||||
|
||||
$this->adapter->mirror_insert( 100, $comment );
|
||||
$this->assertSame( array(), $wpdb->queries );
|
||||
}
|
||||
|
||||
public function test_doctor_check_reports_missing_table_in_stub_env(): void {
|
||||
$result = $this->adapter->doctor_check();
|
||||
$this->assertFalse( $result['ok'] );
|
||||
$this->assertStringContainsString( 'wpdo_comment_hp_message', $result['message'] );
|
||||
}
|
||||
|
||||
public function test_score_aggregates_to_perfect_ten(): void {
|
||||
$this->assertSame( 10.0, $this->adapter->suitability_score()['aggregate'] );
|
||||
public function test_migrations_is_empty(): void {
|
||||
$this->assertSame( array(), $this->adapter->migrations() );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,67 +35,25 @@ class HivePressRequestsAdapterTest extends TestCase {
|
||||
$this->assertSame( 'hp_offer', WPDO_HivePress_Requests_Adapter::OFFER_COMMENT_TYPE );
|
||||
}
|
||||
|
||||
public function test_on_register_fields_registers_request_hot_columns(): void {
|
||||
$registry = WPDO_Schema_Registry::instance();
|
||||
$this->adapter->on_register_fields( $registry );
|
||||
|
||||
$drafted = $registry->get_field( 'hp_request', 'hp_drafted' );
|
||||
$this->assertSame( 'hot', $drafted['zone'] );
|
||||
$this->assertTrue( (bool) $drafted['indexed'] );
|
||||
|
||||
$expired = $registry->get_field( 'hp_request', 'hp_expired_time' );
|
||||
$this->assertSame( 'hot', $expired['zone'] );
|
||||
$this->assertTrue( (bool) $expired['indexed'] );
|
||||
}
|
||||
|
||||
public function test_on_register_custom_tables_declares_offer_shadow(): void {
|
||||
public function test_registers_no_offer_shadow_table(): void {
|
||||
// The offer shadow table was never created, so every mirror write failed.
|
||||
$registry = WPDO_Custom_Table_Registry::instance();
|
||||
$this->adapter->on_register_custom_tables( $registry );
|
||||
|
||||
$tables = $registry->all();
|
||||
$cfg = reset( $tables );
|
||||
$this->assertSame( 'wpdo_comment_hp_offer', $cfg['table_name'] );
|
||||
$this->assertSame( 'hp_request', $cfg['post_type_link'] );
|
||||
$this->assertArrayHasKey( 'idx_request_approved', $cfg['indexes'] );
|
||||
$this->assertSame( array(), $registry->all() );
|
||||
}
|
||||
|
||||
public function test_mirror_offer_insert_skips_other_comment_types(): void {
|
||||
global $wpdb;
|
||||
$wpdb->queries = array();
|
||||
public function test_binds_no_comment_hooks(): void {
|
||||
$GLOBALS['_wp_filter_callbacks'] = array();
|
||||
|
||||
$comment = new stdClass();
|
||||
$comment->comment_type = 'comment'; // ← not hp_offer
|
||||
$comment->comment_post_ID = 99;
|
||||
$comment->user_id = 1;
|
||||
$this->adapter->on_register_event_hooks();
|
||||
|
||||
$this->adapter->mirror_offer_insert( 1, $comment );
|
||||
$this->assertSame( array(), $wpdb->queries );
|
||||
}
|
||||
|
||||
public function test_mirror_offer_insert_writes_for_hp_offer(): void {
|
||||
global $wpdb;
|
||||
$wpdb->queries = array();
|
||||
|
||||
$comment = new stdClass();
|
||||
$comment->comment_type = 'hp_offer';
|
||||
$comment->comment_post_ID = 42;
|
||||
$comment->user_id = 7;
|
||||
$comment->comment_approved = 0;
|
||||
$comment->comment_date = '2026-05-04 12:00:00';
|
||||
|
||||
$this->adapter->mirror_offer_insert( 100, $comment );
|
||||
$this->assertCount( 1, $wpdb->queries );
|
||||
$this->assertStringContainsString( 'INSERT INTO', $wpdb->queries[0] );
|
||||
$this->assertStringContainsString( 'wpdo_comment_hp_offer', $wpdb->queries[0] );
|
||||
$this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
|
||||
$this->assertArrayNotHasKey( 'edit_comment', $GLOBALS['_wp_filter_callbacks'] );
|
||||
$this->assertArrayNotHasKey( 'deleted_comment', $GLOBALS['_wp_filter_callbacks'] );
|
||||
}
|
||||
|
||||
public function test_score_aggregates_to_perfect_ten(): void {
|
||||
$this->assertSame( 10.0, $this->adapter->suitability_score()['aggregate'] );
|
||||
}
|
||||
|
||||
public function test_migrations_lists_request_and_offer_modules(): void {
|
||||
$mods = $this->adapter->migrations();
|
||||
$this->assertContains( 'hot_hp_request', $mods );
|
||||
$this->assertContains( 'comment_hp_offer', $mods );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ class HivePressSocialLinksAdapterTest extends TestCase {
|
||||
$prop->setAccessible( true );
|
||||
$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();
|
||||
}
|
||||
|
||||
@@ -30,25 +32,39 @@ class HivePressSocialLinksAdapterTest extends TestCase {
|
||||
$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();
|
||||
$this->adapter->on_register_fields( $registry );
|
||||
|
||||
// All seven social URL fields should land in cold zone on hp_vendor.
|
||||
$facebook = $registry->get_field( 'hp_vendor', 'hp_facebook_url' );
|
||||
$this->assertSame( 'cold', $facebook['zone'] );
|
||||
$this->assertSame( 'cold', $registry->get_field( 'hp_vendor', 'hp_facebook' )['zone'] );
|
||||
$this->assertSame( 'cold', $registry->get_field( 'hp_vendor', 'hp_website' )['zone'] );
|
||||
$this->assertSame( 'cold', $registry->get_field( 'hp_listing', 'hp_instagram' )['zone'] );
|
||||
|
||||
$instagram = $registry->get_field( 'hp_vendor', 'hp_instagram_url' );
|
||||
$this->assertSame( 'cold', $instagram['zone'] );
|
||||
|
||||
$whatsapp = $registry->get_field( 'hp_vendor', 'hp_whatsapp_url' );
|
||||
$this->assertSame( 'cold', $whatsapp['zone'] );
|
||||
$this->assertNull( $registry->get_field( 'hp_vendor', 'hp_instagram' ) );
|
||||
$this->assertNull( $registry->get_field( 'hp_vendor', 'hp_facebook_url' ) );
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
$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 {
|
||||
|
||||
@@ -7,7 +7,7 @@ declare(strict_types=1);
|
||||
* Several existing wpdo tests (ZoneHotTest, SyncBridgeTest, etc.) replace the
|
||||
* global $wpdb with their own anonymous-class mock and never restore it. When
|
||||
* our adapter tests run after those, $wpdb lacks the query/get_col/delete
|
||||
* methods our mirror_insert / cron-optimizer code paths call, causing
|
||||
* methods our cron-optimizer code paths call, causing
|
||||
* cross-test pollution failures.
|
||||
*
|
||||
* Trait reinstalls a known $wpdb mock + clears the wpdo_features option so
|
||||
|
||||
Reference in New Issue
Block a user