chore: initial snapshot of 2meet-data-optimizer-hivepress-addon v0.1.0

Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit b4400a68e5
56 changed files with 10395 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
vendor/
node_modules/
.phpunit.result.cache
.phpcs-cache
*.log
*.bak
*.swp
.DS_Store
Thumbs.db
.idea/
.vscode/
.full-review/
.gstack/
.playwright-mcp/
.claude/
dist/
+37
View File
@@ -0,0 +1,37 @@
<?php
/**
* 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
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
* Requires Plugins: 2meet-data-optimizer, hivepress
* Author: 2meet
* Author URI: https://2meet.io
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: tmdo-hivepress
* Domain Path: /languages
*
* @package TMDO_HIVEPRESS
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'TMDO_HIVEPRESS_VERSION', '0.1.0' );
define( 'TMDO_HIVEPRESS_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_HIVEPRESS_URL', plugin_dir_url( __FILE__ ) );
define( 'TMDO_HIVEPRESS_FILE', __FILE__ );
require_once TMDO_HIVEPRESS_PATH . 'includes/class-tmdo-hivepress-bootstrap.php';
/**
* Phase 3 任務:完成本 AddOn 的 includes/ 實作。
*
* @see ~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md
*/
add_action( 'plugins_loaded', array( 'TMDO_HP_Bootstrap', 'init' ), 6 );
+22
View File
@@ -0,0 +1,22 @@
# Changelog
All notable changes to this AddOn will be documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versioning follows [Semantic Versioning](https://semver.org/).
---
## [Unreleased]
- Phase 3 implementation in progress.
---
## [0.1.0] — TBD
### Added (initial release, scaffold)
- Plugin scaffold from `wp-data-optimizer v2.16.0` extraction plan.
- Plugin Header + bootstrap stub + 7 MD + composer.json.
- Requires Plugins: `2meet-data-optimizer`, `hivepress`.
+74
View File
@@ -0,0 +1,74 @@
# CLAUDE.md — 2meet Data Optimizer Hivepress AddOn
> 父外掛:`../2meet-data-optimizer/CLAUDE.md`
> 父環境:`/var/www/Studio/wp-local-dev30/CLAUDE.md`
> 全域:`~/.claude/CLAUDE.md`(含 Karpathy Guidelines
---
## Reset recovery
1. 讀本檔
2. 讀 [PLAN.md](PLAN.md) 看 Phase 3 進度
3. 接續下一個未完成步驟
---
## Plugin 概述
**2meet-data-optimizer-hivepress-addon** 是 2meet-data-optimizer 的 Hivepress 整合 AddOn。
- **目標外掛**: `hivepress`
- **Phase**: 3
- **預估行數**: ~7400
- **Slug 短碼**: `hivepress`
簡述:HivePress + 12 擴充整合:13 adapter + 7 interceptor + 5 query interceptor + bootstrap + admin + CLI
完整拆分計畫見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md`
---
## 邊界
- ✅ 本 AddOn 觸碰:hivepress 相關 schema 註冊(postmeta zone fields / custom tables / entity groups+ 可選 admin / CLI
- ❌ 本 AddOn **不**觸碰:2meet-data-optimizer 核心、其他 AddOn、目標外掛 runtime 邏輯
- ❌ 本 AddOn **不**繞過:Hook Bus / TMDO_API(必須走公開合約)
---
## 命名前綴
- 類別:`TMDO_Hivepress_*`
- 函式:`tmdo_hivepress_*`
- 常數:`TMDO_HIVEPRESS_VERSION` / `TMDO_HIVEPRESS_PATH` / `TMDO_HIVEPRESS_URL` / `TMDO_HIVEPRESS_FILE`
- Text-domain`tmdo-hivepress`
---
## 公開合約消費(從核心)
```php
// 安全消費 API(必加 class_exists guard
if ( class_exists( 'TMDO_API' ) ) {
TMDO_API::set_entity( 'user', $id, 'key', $value );
}
// Schema 註冊
add_action( 'tmdo_register_entity_fields', function ( $registry_class ) {
$registry_class::register_group( '...', '...', [ ... ] );
} );
add_action( 'tmdo_register_custom_tables', function ( $registry ) {
$registry->register( 'hivepress-addon', [ ... ] );
} );
```
---
## Phase 進度
```
Phase 0 ✅ 骨架建立 (2026-05-15)
Phase 3 ⬜ Implementation v0.1.0
```
+43
View File
@@ -0,0 +1,43 @@
# 部署流程
## Prerequisites
1. `2meet-data-optimizer` 核心 ≥ 0.1.0 已安裝並啟用
2. `hivepress` 已安裝並啟用
> 本 AddOn 透過 WordPress `Requires Plugins:` 強制相依,先決條件未滿足時無法啟用。
---
## Steps
```bash
# 上傳 + 啟用
wp plugin install /path/to/2meet-data-optimizer-hivepress-addon-v0.1.0.zip --activate
# 驗證
wp tmdo doctor --provider=hivepress-addon
wp tmdo custom-tables --provider=hivepress-addon
```
---
## Rollback
```bash
wp plugin deactivate 2meet-data-optimizer-hivepress-addon
# 目標外掛 (hivepress) 仍可正常運作,
# 讀寫 fall back 到 native meta(失去 zone 加速但功能不受影響)
```
---
## 整體部署順序建議
依 Phase 順序:
1. `2meet-data-optimizer` 核心
2. Phase 2: `2meet-data-optimizer-hub-addon``2meet-data-optimizer-spoke-addon`
3. Phase 3: HivePress / WooCommerce / LatePoint AddOn
4. Phase 4: 6 個 2meet 家族 AddOn
詳見核心外掛 [DEPLOY.md](../2meet-data-optimizer/DEPLOY.md)。
+44
View File
@@ -0,0 +1,44 @@
# DESIGN — 2meet Data Optimizer Hivepress AddOn
> 父外掛架構決策見 [`../2meet-data-optimizer/DESIGN.md`](../2meet-data-optimizer/DESIGN.md)
---
## 角色定位
本 AddOn 是 `2meet-data-optimizer` 的整合層,負責將 `hivepress` 的 EAV 模式(postmeta / 自訂表)橋接到核心反 EAV 引擎。
- **不**修改 `hivepress` 本身
- **不**繞過 `2meet-data-optimizer` 核心
- 只透過公開合約(`TMDO_API``tmdo_register_*` hook)參與
---
## 啟動順序
```
plugins_loaded:4 2meet-data-optimizer (core) 啟動
plugins_loaded:5-6 本 AddOn bootstrap → 註冊 hook callback
plugins_loaded:6+ hivepress 本體啟動
init:0 核心 fire tmdo_register_* → 本 AddOn 的 callback 執行
```
詳見 plan 文件第 3 章「載入順序」。
---
## 為什麼是獨立 AddOn 而非塞進核心
- 核心 `2meet-data-optimizer` 對外承諾「零依存任何業務外掛」
- 整合層發版節奏應跟隨 `hivepress` 而非核心
- 站台不用 `hivepress` 時不需被迫載入這段程式碼
---
## 邊界檢查清單
- [ ] 不直接 `SELECT FROM wp_*meta`
- [ ] 不繞過 Hook Bus
- [ ] 所有 SQL 走 `$wpdb->prepare()`
- [ ] DB 表名走 `TMDO_DB::table()`
- [ ] 對外只 expose `TMDO_Hivepress_*` 命名空間
+43
View File
@@ -0,0 +1,43 @@
# PLAN — 2meet Data Optimizer Hivepress AddOn
> 完整四階段拆分計畫見 `~/.claude/plans/wp-data-optimizer-2meet-data-optimizer-zesty-liskov.md` (本 AddOn 屬 **Phase 3**)
---
## Current state
- **Version**: 0.1.0 (in development, scaffold complete 2026-05-15)
- **Phase**: Phase 0 → Phase 3 pending
- **Source**: 從 `wp-data-optimizer v2.16.0` 提煉
- **預估行數**: ~7400 行
- **目標外掛**: `hivepress`
---
## Phase 0 ✅ — 骨架建立 (2026-05-15)
- [x] 目錄結構
- [x] Plugin Header
- [x] Bootstrap stub
- [x] 7 件 MD
- [x] composer.json / .gitignore / uninstall.php
---
## Phase 3 ⬜ — Implementation v0.1.0
詳細任務見上層 plan 第 5 章。簡述:
- [ ] 從 wp-data-optimizer 對應檔案搬入 includes/
- [ ] WPDO_ → TMDO_Hivepress_ 重命名
- [ ] 移除對 wp-data-optimizer 內部 class 的依存(改走 TMDO_API 公開合約)
- [ ] 完善 bootstrap:實作 init() 載入順序
- [ ] 補完 7 件 MD 的細節
- [ ]`scripts/package-plugin.sh 2meet-data-optimizer-hivepress-addon` 通過 10 終檢
- [ ] dev30 e2e
---
## Lessons learned
- (Phase 3 起記錄)
+104
View File
@@ -0,0 +1,104 @@
# 2meet Data Optimizer — HivePress AddOn
> 為 **HivePress** + 12 個官方擴充提供完整反 EAV 整合(最大 AddOn~7,400 行)。
---
## What it does
`wp-data-optimizer v2.16.0` 提煉的 HivePress 整合層,包含 13 個 adapter、7 個 HPCT 攔截器、5 個查詢攔截器、bootstrap、admin tab、CLI namespace。
### 13 HP Adapter 全清單
| Adapter | 對應 HP 擴充 | 行數 |
|---|---|---:|
| Core | hivepress | 407 |
| Reviews | hivepress-reviews | 146 |
| Bookings | hivepress-bookings | 159 |
| Messages | hivepress-messages | 263 |
| Memberships | hivepress-memberships | 169 |
| Requests | hivepress-requests | 267 |
| Favorites | hivepress-favorites | 234 |
| Statistics | hivepress-statistics | 130 |
| Tags | hivepress-tags | 84 |
| SEO | hivepress-seo | 76 |
| Social Links | hivepress-social-links | 115 |
| Blocks | hivepress-blocks | 73 |
| Marketplace | hivepress-marketplace | 135 |
### 7 HPCT Interceptor
reviews / messages / favorites / memberships / requests / statistics / listing-meta
### 5 Query Interceptor (pre_get_posts 改寫)
reviews / messages / memberships / requests / listing-meta
### 額外組件
- HP transient filter`_transient_hp_*` postmeta → wp_options 路由,避免 postmeta 脹大)
- HPCT (HP Custom Tables) import 工具(v2.0.0 取代 HPCT 之相容路徑)
- HP attribute bridge / cron optimizer / suitability scorer / benchmark / REST
- HivePress comment router
---
## Requirements
- WordPress ≥ 6.0
- PHP ≥ 8.1
- 2meet-data-optimizer ≥ 0.1.0
- hivepress ≥ 1.7.22 (支援 `_alias` / `_external` field convention)
---
## Installation
```bash
# 啟用前確認 HP 已啟用
wp plugin list | grep hivepress
# 安裝 + 啟用
wp plugin install /path/to/2meet-data-optimizer-hivepress-addon-v0.1.0.zip --activate
# 驗證 13 adapter 全 bound
wp tmdo hivepress detect
# Benchmark vs HP 預設
wp tmdo hivepress benchmark --post-type=hp_listing --samples=200
```
---
## 為什麼選擇單一 AddOn(不拆 13 sub-AddOn
| 維度 | 單一 AddOn ✓ | 13 sub-AddOn ✗ |
|---|---|---|
| 啟動開銷 | 一次偵測 | 13 次重複偵測 |
| 依存管理 | 單一 ZIP | WP 依存解析複雜 |
| Feature flags | 統一管理 | 跨 sub-AddOn 同步 |
| UX | 一鍵啟用 | 分次啟用 |
---
## CLI
```bash
wp tmdo hivepress detect
wp tmdo hivepress benchmark
wp tmdo hivepress import-hpct --dry-run
wp tmdo hivepress score # 8 維 anti-EAV 評分
wp tmdo hivepress doctor
```
---
## Phase
Phase 3 — 與 [WooCommerce AddOn](../2meet-data-optimizer-woocommerce-addon/) 與 [LatePoint AddOn](../2meet-data-optimizer-latepoint-addon/) 同期 ship。風險最高的 phase。
---
## License
GPL-2.0-or-later
+36
View File
@@ -0,0 +1,36 @@
# Security Policy
## Supported versions
| Version | Supported |
|---|:-:|
| 0.1.x | ✅ |
---
## Reporting a vulnerability
請參考核心外掛 [SECURITY.md](../2meet-data-optimizer/SECURITY.md)。
簡述:
- Email: security@2meet.io
- 請勿在 public GitHub issue 揭露
- 24h 內初步回應、7 天內提供修補時程
---
## 安全設計
本 AddOn 完全遵守 2meet-data-optimizer 核心安全設計:
- SQL prepared statements
- Capability check (`manage_options`)
- Nonce 驗證
- Output escaping
- 反序列化走 `TMDO_Safe_Unserialize`
---
## 已知 Risk
- (本版本暫無已知安全 riskPhase 3 ship 時補)
+164
View File
@@ -0,0 +1,164 @@
<?php
/**
* Admin tab renderer for the HivePress integration.
*
* Read-only tab. Shows:
*
* 1. Conflict guard status (hp-custom-tables / hp-info-cards)
* 2. 13 known addons + detected/bound state
* 3. 8-D suitability aggregate + per-adapter scores
*
* Mutating actions (migrate / rollback) are CLI-only — see
* `wp wpdo hivepress migrate <slug>`. The admin tab links to the CLI in its
* footer (Karpathy: blast-radius-aware UI design).
*
* Plugged into `TMDO_Admin::render_page()` switch via `'hivepress' =>
* 'HivePress 整合'` tab entry.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_Admin_HivePress' ) ) {
/**
* Static renderer for the HivePress admin tab.
*/
final class TMDO_Admin_HivePress {
/**
* Render the tab body.
*
* @return void
*/
public static function render(): void {
$detected = class_exists( 'TMDO_HivePress_Detector' )
? TMDO_HivePress_Detector::detect()
: array();
$catalog = class_exists( 'TMDO_HivePress_Detector' )
? TMDO_HivePress_Detector::catalog()
: array();
$bound = class_exists( 'TMDO_HivePress_Bootstrap' )
? array_keys( TMDO_HivePress_Bootstrap::adapters() )
: array();
$conflicts = class_exists( 'TMDO_HivePress_Conflict_Guard' )
? TMDO_HivePress_Conflict_Guard::detect_conflicts()
: array();
$report = class_exists( 'TMDO_HivePress_Suitability_Scorer' )
? TMDO_HivePress_Suitability_Scorer::report()
: array(
'aggregate' => 0.0,
'adapter_count' => 0,
'adapters' => array(),
'per_dimension' => array(),
);
?>
<div class="wpdo-hp-tab">
<h2><?php esc_html_e( 'HivePress 家族整合', 'tmdo-hivepress' ); ?></h2>
<?php self::render_conflict_section( $conflicts ); ?>
<h3><?php esc_html_e( '已偵測 addon', 'tmdo-hivepress' ); ?></h3>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e( 'Slug', 'tmdo-hivepress' ); ?></th>
<th><?php esc_html_e( '名稱', 'tmdo-hivepress' ); ?></th>
<th><?php esc_html_e( '偵測', 'tmdo-hivepress' ); ?></th>
<th><?php esc_html_e( '版本', 'tmdo-hivepress' ); ?></th>
<th><?php esc_html_e( 'Adapter 已綁定', 'tmdo-hivepress' ); ?></th>
<th><?php esc_html_e( '8-D Score', 'tmdo-hivepress' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $catalog as $slug => $name ) : ?>
<?php
$is_detected = isset( $detected[ $slug ] );
$is_bound = in_array( $slug, $bound, true );
$score = $report['adapters'][ $slug ]['aggregate'] ?? null;
?>
<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( $detected[ $slug ] ?? '—' ); ?></td>
<td><?php echo $is_bound ? '✅' : '—'; ?></td>
<td><?php echo null === $score ? '—' : esc_html( (string) $score ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<h3 style="margin-top:24px;"><?php esc_html_e( '8 維度反 EAV 評分', 'tmdo-hivepress' ); ?></h3>
<p>
<?php
echo esc_html(
sprintf(
/* translators: %1$s: aggregate score, %2$d: adapter count. */
__( 'Aggregate: %1$s / 10 · Active adapters: %2$d', 'tmdo-hivepress' ),
(string) $report['aggregate'],
(int) $report['adapter_count']
)
);
?>
</p>
<?php self::render_dimension_table( $report['per_dimension'] ); ?>
<h3 style="margin-top:24px;"><?php esc_html_e( '操作 (CLI)', 'tmdo-hivepress' ); ?></h3>
<p><?php esc_html_e( '為避免誤觸生產資料,遷移與 rollback 僅透過 CLI 執行:', 'tmdo-hivepress' ); ?></p>
<pre style="background:#f6f7f7;padding:12px;border-left:4px solid #2271b1;">wp wpdo hivepress detect
wp wpdo hivepress score
wp wpdo hivepress doctor
wp wpdo hivepress migrate &lt;addon-slug&gt;
wp wpdo hivepress rollback &lt;addon-slug&gt;
wp wpdo hivepress benchmark</pre>
</div>
<?php
}
/**
* Render the conflict-guard banner (if applicable).
*
* @param array<int,string> $conflicts Conflicting plugin slugs.
*/
private static function render_conflict_section( array $conflicts ): void {
if ( empty( $conflicts ) ) {
echo '<div class="notice notice-success inline"><p>'
. esc_html__( 'Conflict guard: clean — hp-custom-tables / hp-info-cards 未啟用。', 'tmdo-hivepress' )
. '</p></div>';
return;
}
echo '<div class="notice notice-warning inline"><p><strong>'
. esc_html__( '偵測到衝突外掛:', 'tmdo-hivepress' ) . '</strong> '
. esc_html( implode( ', ', $conflicts ) )
. '</p><p>'
. esc_html__( 'WPDO HivePress integration 已自動退讓。請執行 `wp wpdo hivepress migrate-from-hpct` 後停用舊外掛。', 'tmdo-hivepress' )
. '</p></div>';
}
/**
* Render per-dimension averages table.
*
* @param array<string,float> $dimensions Dimension key → average.
*/
private static function render_dimension_table( array $dimensions ): void {
if ( empty( $dimensions ) ) {
return;
}
echo '<table class="widefat striped" style="max-width:520px;"><thead><tr><th>'
. esc_html__( '維度', 'tmdo-hivepress' ) . '</th><th>'
. esc_html__( '平均', 'tmdo-hivepress' ) . '</th></tr></thead><tbody>';
foreach ( $dimensions as $key => $avg ) {
echo '<tr><td><code>' . esc_html( $key ) . '</code></td><td>' . esc_html( (string) $avg ) . '</td></tr>';
}
echo '</tbody></table>';
}
}
} // end if ( ! class_exists )
+343
View File
@@ -0,0 +1,343 @@
<?php
/**
* WP-CLI commands for the HivePress family integration.
*
* Registered as a namespace under `wp wpdo hivepress`:
*
* wp wpdo hivepress detect — list installed HivePress family addons
* wp wpdo hivepress score — 8-dimension suitability report
* wp wpdo hivepress doctor — health probe each active adapter
* wp wpdo hivepress migrate — flip an addon's modules to cutover state
* wp wpdo hivepress rollback — reset an addon's modules to idle
* wp wpdo hivepress benchmark — run sample query timings (no-op when adapters inactive)
*
* Per Karpathy guideline: the CLI is a thin wrapper over the introspection
* helpers built in Sprints 1-3 (detector / suitability-scorer / benchmark).
* No new business logic lives here.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_CLI_HivePress' ) ) {
/**
* `wp wpdo hivepress <subcommand>` namespace handler.
*/
class TMDO_CLI_HivePress {
/**
* List installed HivePress family addons + adapter binding state.
*
* ## OPTIONS
*
* [--format=<format>]
* : Output format (table|json|csv|yaml). Default: table.
*
* ## EXAMPLES
*
* wp wpdo hivepress detect
* wp wpdo hivepress detect --format=json
*
* @param array $args Positional args (unused).
* @param array $assoc_args Flag args.
*
* @subcommand detect
*/
public function detect( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
unset( $args );
$format = (string) ( $assoc_args['format'] ?? 'table' );
$detected = class_exists( 'TMDO_HivePress_Detector' )
? TMDO_HivePress_Detector::detect()
: array();
$catalog = class_exists( 'TMDO_HivePress_Detector' )
? TMDO_HivePress_Detector::catalog()
: array();
$bound = class_exists( 'TMDO_HivePress_Bootstrap' )
? array_keys( TMDO_HivePress_Bootstrap::adapters() )
: array();
$rows = array();
foreach ( $catalog as $slug => $name ) {
$rows[] = array(
'slug' => $slug,
'name' => $name,
'detected' => isset( $detected[ $slug ] ) ? 'yes' : 'no',
'version' => $detected[ $slug ] ?? '-',
'bound' => in_array( $slug, $bound, true ) ? 'yes' : 'no',
);
}
if ( ! class_exists( 'WP_CLI\\Utils' ) || 'table' === $format ) {
WP_CLI::log( '=== HivePress family detection ===' );
WP_CLI::log( '' );
WP_CLI::log( str_pad( 'slug', 26 ) . str_pad( 'detected', 12 ) . str_pad( 'version', 10 ) . 'bound' );
WP_CLI::log( str_repeat( '-', 60 ) );
foreach ( $rows as $row ) {
WP_CLI::log( str_pad( $row['slug'], 26 ) . str_pad( $row['detected'], 12 ) . str_pad( $row['version'], 10 ) . $row['bound'] );
}
WP_CLI::log( '' );
WP_CLI::success( sprintf( 'Detected %d / %d addons', count( $detected ), count( $catalog ) ) );
return;
}
\WP_CLI\Utils\format_items( $format, $rows, array( 'slug', 'name', 'detected', 'version', 'bound' ) );
}
/**
* Show 8-dimension suitability report (per-adapter + aggregate).
*
* ## OPTIONS
*
* [--addon=<slug>]
* : Filter to a single addon slug.
*
* [--format=<format>]
* : Output format (table|json|yaml). Default: table.
*
* ## EXAMPLES
*
* wp wpdo hivepress score
* wp wpdo hivepress score --addon=hivepress-reviews
* wp wpdo hivepress score --format=json
*
* @param array $args Positional args (unused).
* @param array $assoc_args Flag args.
*
* @subcommand score
*/
public function score( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
unset( $args );
if ( ! class_exists( 'TMDO_HivePress_Suitability_Scorer' ) ) {
WP_CLI::error( 'TMDO_HivePress_Suitability_Scorer not loaded.' );
}
$report = TMDO_HivePress_Suitability_Scorer::report();
$addon = isset( $assoc_args['addon'] ) ? (string) $assoc_args['addon'] : '';
$format = (string) ( $assoc_args['format'] ?? 'table' );
if ( '' !== $addon ) {
if ( ! isset( $report['adapters'][ $addon ] ) ) {
WP_CLI::error( sprintf( 'Addon "%s" not active.', $addon ) );
}
$score = $report['adapters'][ $addon ];
if ( 'json' === $format || 'yaml' === $format ) {
\WP_CLI\Utils\format_items( $format, array( $score ), array_keys( $score ) );
return;
}
WP_CLI::log( sprintf( '=== Score: %s ===', $addon ) );
foreach ( $score as $k => $v ) {
WP_CLI::log( sprintf( ' %s: %s', str_pad( $k, 22 ), $v ) );
}
return;
}
if ( 'json' === $format || 'yaml' === $format ) {
\WP_CLI\Utils\format_items( $format, array( $report ), array( 'aggregate', 'adapter_count', 'per_dimension', 'adapters' ) );
return;
}
WP_CLI::log( '=== HivePress 8-D Suitability Report ===' );
WP_CLI::log( '' );
WP_CLI::log( sprintf( 'Active adapters: %d', $report['adapter_count'] ) );
WP_CLI::log( sprintf( 'Aggregate score: %s / 10', $report['aggregate'] ) );
WP_CLI::log( '' );
WP_CLI::log( 'Per-dimension averages:' );
foreach ( $report['per_dimension'] as $dim => $avg ) {
WP_CLI::log( sprintf( ' %s: %s', str_pad( $dim, 22 ), $avg ) );
}
WP_CLI::log( '' );
WP_CLI::log( 'Per-adapter aggregate:' );
foreach ( $report['adapters'] as $slug => $score ) {
WP_CLI::log( sprintf( ' %s → %s', str_pad( $slug, 26 ), $score['aggregate'] ?? '-' ) );
}
}
/**
* Run doctor probe for each active adapter.
*
* ## OPTIONS
*
* [--addon=<slug>]
* : Limit probe to a single addon.
*
* ## EXAMPLES
*
* wp wpdo hivepress doctor
* wp wpdo hivepress doctor --addon=hivepress-favorites
*
* @param array $args Positional args (unused).
* @param array $assoc_args Flag args.
*
* @subcommand doctor
*/
public function doctor( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
unset( $args );
$adapters = class_exists( 'TMDO_HivePress_Bootstrap' )
? TMDO_HivePress_Bootstrap::adapters()
: array();
$only = isset( $assoc_args['addon'] ) ? (string) $assoc_args['addon'] : '';
$failed = 0;
$total = 0;
foreach ( $adapters as $slug => $adapter ) {
if ( '' !== $only && $slug !== $only ) {
continue;
}
if ( ! $adapter instanceof TMDO_HivePress_Adapter ) {
continue;
}
++$total;
$probe = $adapter->doctor_check();
if ( ! empty( $probe['ok'] ) ) {
WP_CLI::log( sprintf( '✓ %s: %s', $slug, $probe['message'] ?? 'OK' ) );
} else {
WP_CLI::log( sprintf( '✗ %s: %s', $slug, $probe['message'] ?? 'FAIL' ) );
++$failed;
}
}
if ( 0 === $total ) {
WP_CLI::warning( 'No adapters active. Install HivePress first.' );
return;
}
if ( $failed > 0 ) {
WP_CLI::error( sprintf( 'Doctor: %d / %d adapters failed.', $failed, $total ) );
}
WP_CLI::success( sprintf( 'Doctor: %d / %d adapters healthy.', $total, $total ) );
}
/**
* Flip an addon's modules to a target FSM state (default: cutover).
*
* ## OPTIONS
*
* <slug>
* : Addon slug (e.g. hivepress-reviews).
*
* [--state=<state>]
* : Target state. One of: dual_write, backfill, verify, cutover, cleanup, complete. Default: cutover.
*
* ## EXAMPLES
*
* wp wpdo hivepress migrate hivepress-reviews
* wp wpdo hivepress migrate hivepress-favorites --state=dual_write
*
* @param array $args Positional args (slug).
* @param array $assoc_args Flag args.
*
* @subcommand migrate
*/
public function migrate( $args, $assoc_args ): void {
$slug = (string) ( $args[0] ?? '' );
$state = (string) ( $assoc_args['state'] ?? 'cutover' );
$this->set_state( $slug, $state );
}
/**
* Roll an addon's modules back to idle.
*
* ## OPTIONS
*
* <slug>
* : Addon slug.
*
* ## EXAMPLES
*
* wp wpdo hivepress rollback hivepress-reviews
*
* @param array $args Positional args (slug).
* @param array $assoc_args Flag args (unused).
*
* @subcommand rollback
*/
public function rollback( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
unset( $assoc_args );
$slug = (string) ( $args[0] ?? '' );
$this->set_state( $slug, 'idle' );
}
/**
* Run benchmark suite for active adapters.
*
* Currently a placeholder that reports availability — real query-pair
* benchmarks are wired by individual addons during their migration
* tooling. This command lists which adapters can be benchmarked.
*
* ## EXAMPLES
*
* wp wpdo hivepress benchmark
*
* @param array $args Positional args (unused).
* @param array $assoc_args Flag args (unused).
*
* @subcommand benchmark
*/
public function benchmark( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
unset( $args, $assoc_args );
if ( ! class_exists( 'TMDO_HivePress_Benchmark' ) ) {
WP_CLI::error( 'TMDO_HivePress_Benchmark not loaded.' );
}
$adapters = class_exists( 'TMDO_HivePress_Bootstrap' )
? TMDO_HivePress_Bootstrap::adapters()
: array();
if ( empty( $adapters ) ) {
WP_CLI::warning( 'No adapters active — no benchmark to run.' );
return;
}
WP_CLI::log( '=== HivePress benchmark availability ===' );
foreach ( $adapters as $slug => $adapter ) {
$mods = $adapter instanceof TMDO_HivePress_Adapter ? $adapter->migrations() : array();
if ( empty( $mods ) ) {
WP_CLI::log( sprintf( ' %s: no FSM modules', $slug ) );
continue;
}
WP_CLI::log( sprintf( ' %s: %s', $slug, implode( ', ', $mods ) ) );
}
WP_CLI::log( '' );
WP_CLI::log( 'Run `wp wpdo benchmark <module>` for per-module timings.' );
}
// ── Internal helpers ────────────────────────────────────────────────
/**
* Apply a target FSM state to all modules owned by an addon's adapter.
*
* @param string $slug Addon slug.
* @param string $state Target state.
*/
private function set_state( string $slug, string $state ): void {
if ( '' === $slug ) {
WP_CLI::error( 'Missing addon slug.' );
}
if ( ! in_array( $state, TMDO_Feature_Flags::VALID_STATES, true ) ) {
WP_CLI::error( sprintf( 'Invalid state "%s". Valid: %s', $state, implode( ', ', TMDO_Feature_Flags::VALID_STATES ) ) );
}
$adapter = class_exists( 'TMDO_HivePress_Bootstrap' )
? TMDO_HivePress_Bootstrap::adapter_for( $slug )
: null;
if ( ! $adapter instanceof TMDO_HivePress_Adapter ) {
WP_CLI::error( sprintf( 'Adapter "%s" not active.', $slug ) );
}
$modules = $adapter->migrations();
if ( empty( $modules ) ) {
WP_CLI::warning( sprintf( 'Adapter "%s" has no FSM modules.', $slug ) );
return;
}
foreach ( $modules as $module ) {
TMDO_Feature_Flags::set( (string) $module, $state );
WP_CLI::log( sprintf( ' %s → %s', $module, $state ) );
}
WP_CLI::success( sprintf( 'Adapter "%s" modules set to %s.', $slug, $state ) );
}
}
} // end if ( ! class_exists )
+25
View File
@@ -0,0 +1,25 @@
{
"name": "2meet/2meet-data-optimizer-hivepress-addon",
"description": "HivePress + 12 擴充整合:13 adapter + 7 interceptor + 5 query interceptor + bootstrap + admin + CLI",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"keywords": ["wordpress", "2meet-data-optimizer", "addon", "hivepress"],
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"wp-coding-standards/wpcs": "^3.0"
},
"scripts": {
"phpcs": "phpcs --standard=WordPress includes/ 2meet-data-optimizer-hivepress-addon.php"
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
},
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
}
}
Generated
+2106
View File
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
<?php
/**
* Bootstrap for 2meet Data Optimizer HivePress AddOn.
*
* 載入 13 adapter + 7 interceptor + 5 query interceptor + 補助組件 +
* admin tab + CLI namespace。
*
* 注意:HP 的 V3.0.0 family bootstrap 內部也有一個 class TMDO_HivePress_Bootstrap
* (在 includes/hivepress/class-tmdo-hivepress-bootstrap.php)。為避免衝突,本
* AddOn outer bootstrap 改名為 TMDO_HP_Bootstrap。
*
* @package TMDO_HIVEPRESS
* @since 0.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class TMDO_HP_Bootstrap {
public static function init(): void {
if ( ! class_exists( 'TMDO_API' ) && ! class_exists( 'WPDO_API' ) ) {
add_action( 'admin_notices', array( __CLASS__, 'missing_core_notice' ) );
return;
}
load_plugin_textdomain(
'tmdo-hivepress',
false,
dirname( plugin_basename( TMDO_HIVEPRESS_FILE ) ) . '/languages'
);
self::require_files();
self::register_hooks();
}
private static function require_files(): void {
$base = TMDO_HIVEPRESS_PATH;
// HivePress family bootstrap framework
require_once $base . 'includes/hivepress/interface-tmdo-hp-adapter.php';
require_once $base . 'includes/hivepress/trait-tmdo-hp-adapter.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-detector.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-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
foreach ( array(
'core', 'reviews', 'favorites', 'messages', 'memberships',
'requests', 'tags', 'seo', 'social-links', 'blocks',
'bookings', 'marketplace', 'statistics',
) as $adapter_slug ) {
$file = $base . "includes/hivepress/adapters/class-tmdo-hivepress-{$adapter_slug}-adapter.php";
if ( file_exists( $file ) ) {
require_once $file;
}
}
// HP misc components
require_once $base . 'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-benchmark.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-rest.php';
// 7 HPCT interceptors
foreach ( array(
'reviews', 'messages', 'favorites', 'memberships',
'statistics', 'requests', 'listing-meta',
) as $intslug ) {
$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-listing-stats.php';
}
private static function register_hooks(): void {
// HP family v3.0.0 per-addon adapter bootstrap
// (TMDO_HivePress_Bootstrap is the inner family bootstrap class
// from includes/hivepress/class-tmdo-hivepress-bootstrap.php)
if ( class_exists( 'TMDO_HivePress_Bootstrap' )
&& method_exists( 'TMDO_HivePress_Bootstrap', 'boot' ) ) {
TMDO_HivePress_Bootstrap::boot();
}
// 7 HPCT interceptors
foreach ( array(
'TMDO_Reviews_Interceptor',
'TMDO_Messages_Interceptor',
'TMDO_Favorites_Interceptor',
'TMDO_Memberships_Interceptor',
'TMDO_Statistics_Interceptor',
'TMDO_Requests_Interceptor',
'TMDO_Listing_Meta_Interceptor',
) as $cls ) {
if ( class_exists( $cls ) ) {
( new $cls() )->register_hooks();
}
}
// 5 query interceptors
foreach ( array(
'TMDO_Reviews_Query',
'TMDO_Messages_Query',
'TMDO_Memberships_Query',
'TMDO_Requests_Query',
'TMDO_Listing_Meta_Query',
) as $cls ) {
if ( class_exists( $cls ) ) {
( new $cls() )->register_hooks();
}
}
// HP transient filter (init:5)
if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 );
}
// Listing Stats hooks + cron
if ( class_exists( 'TMDO_Listing_Stats' ) ) {
TMDO_Listing_Stats::register_hooks();
TMDO_Listing_Stats::schedule_cron();
}
// Term/Comment fields
if ( class_exists( 'TMDO_Hivepress_Term_Comment_Fields' )
&& method_exists( 'TMDO_Hivepress_Term_Comment_Fields', 'register' ) ) {
TMDO_Hivepress_Term_Comment_Fields::register();
}
// Admin tab
if ( is_admin() ) {
$admin_file = TMDO_HIVEPRESS_PATH . 'admin/class-tmdo-admin-hivepress.php';
if ( file_exists( $admin_file ) ) {
require_once $admin_file;
}
}
// CLI namespace
if ( defined( 'WP_CLI' ) && WP_CLI ) {
$cli_file = TMDO_HIVEPRESS_PATH . 'cli/class-tmdo-cli-hivepress.php';
if ( file_exists( $cli_file ) ) {
require_once $cli_file;
if ( class_exists( 'TMDO_CLI_HivePress' ) ) {
\WP_CLI::add_command( 'tmdo hivepress', 'TMDO_CLI_HivePress' );
\WP_CLI::add_command( 'wpdo hivepress', 'TMDO_CLI_HivePress' );
}
}
}
}
public static function missing_core_notice(): void {
echo '<div class="notice notice-error"><p>';
echo esc_html__( '2meet Data Optimizer HivePress AddOn 需要 2meet-data-optimizer 核心外掛,請先安裝並啟用。', 'tmdo-hivepress' );
echo '</p></div>';
}
}
@@ -0,0 +1,133 @@
<?php
/**
* HivePress term + comment entity field registration (v2.12.2 Phase 2).
*
* Registers two entity groups so the corresponding meta keys are routed
* through Hook Bus to flat tables instead of wp_termmeta / wp_commentmeta:
*
* term:hp_taxonomy → wp_wpdo_term_hp_taxonomy
* - hp_sort_order (int, indexed)
* - hp_default (tinyint, indexed)
* - hp_icon (varchar, hp icon class name)
*
* comment:hp_review → wp_wpdo_comment_hp_review
* - hp_rating (tinyint, indexed)
*
* dev10 inventory match (v2.12.0 size check):
* wp_termmeta keys: hp_sort_order(25), hp_default(20), hp_icon(8) → 53 rows, 100% covered
* wp_commentmeta keys: hp_rating(63) → 63 rows, 100% covered
*
* After Phase 5 mode promote:
* - mode=disabled — registry inert, writes go to wp_termmeta/commentmeta as before
* - mode=dual_write — writes go to BOTH (flat + wp_*meta) for safety
* - mode=shadow_read — writes BOTH, reads come from flat (verification phase)
* - mode=aeav_only — writes only flat, wp_*meta untouched
*
* Pattern mirrors TMDO_Post_Fields::register(). Schema_Manager picks up the
* new groups via TMDO_Entity_Registry::get_pending_schemas() on init:1 and
* creates flat tables idempotently (schema_hash compare, no DDL when
* unchanged).
*
* Keys not registered here (`note_group`, `_2meet_demo_*`, etc.) pass through
* to wp_*meta unchanged — same fall-back behavior as user/post entity bridge.
* Garbage patterns are dropped by TMDO_Term_Comment_Garbage_Filter (v2.12.1).
*
* 🔒 Frozen contract: this class never registers post / user fields. Those
* are owned by TMDO_Post_Fields / TMDO_Member_Fields respectively.
*
* @package WP_Data_Optimizer
* @since 2.12.2
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers HivePress term + comment entity groups.
*/
final class TMDO_Hivepress_Term_Comment_Fields {
/**
* Hook into WPDO entity field registration. Called from TMDO_Core::run()
* via the $partner_integrations bootstrap loop.
*
* @return void
*/
public static function register(): void {
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
}
/**
* Register both entity groups.
*
* @return void
*/
public static function register_entity_fields(): void {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
self::register_term_hp_taxonomy_group();
self::register_comment_hp_review_group();
}
// ── Group definitions ────────────────────────────────────────────────────
/**
* HivePress term taxonomy meta (taxonomies: listing_category, listing_tag,
* listing_warranty, listing_availability, etc.).
*
* @return void
*/
private static function register_term_hp_taxonomy_group(): void {
TMDO_Entity_Registry::register_group(
'term',
'hp_taxonomy',
array(
array(
'key' => 'hp_sort_order',
'type' => 'integer',
'searchable' => true,
'label' => 'HivePress term sort order (used by listing facets)',
),
array(
'key' => 'hp_default',
'type' => 'enum',
'options' => array( '0', '1' ),
'searchable' => true,
'label' => 'HivePress default flag (1 = is the default term)',
),
array(
'key' => 'hp_icon',
'type' => 'text',
'label' => 'HivePress term icon class (e.g. fa-star)',
),
)
);
}
/**
* HivePress comment review meta (review type comments left on hp_listing).
*
* Currently single-key; deliberately register as its own group so future
* review-related keys (hp_review_*, hp_review_visibility, etc.) can be
* added without splitting the schema.
*
* @return void
*/
private static function register_comment_hp_review_group(): void {
TMDO_Entity_Registry::register_group(
'comment',
'hp_review',
array(
array(
'key' => 'hp_rating',
'type' => 'integer',
'searchable' => true,
'label' => 'HivePress 1-5 star rating attached to a review comment',
),
)
);
}
}
@@ -0,0 +1,307 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform integration with HivePress: external data shape transform
/**
* TMDO_Hivepress_Transient_Filter — Reroute HivePress per-post TTL cache from
* wp_postmeta to wp_options (native transient API).
*
* Background
* ----------
* HivePress (`hivepress/includes/components/class-cache.php`) implements an
* entity-scoped TTL cache by storing pairs of `_transient_<name>` and
* `_transient_timeout_<name>` rows in *the entity's metadata table*
* (wp_postmeta / wp_usermeta / wp_termmeta / wp_commentmeta) — calling
* `update_post_meta($post_id, '_transient_…', $value)` directly.
*
* This is a deliberate HivePress design choice (cache invalidation tied to
* the entity's lifecycle), but it produces 816 wp_postmeta rows per
* hp_listing every save_post — completely defeating the visible benefit of
* post-entity reverse-EAV (mode=aeav_only successfully short-circuits the 7
* registered entity keys, but HivePress still bloats wp_postmeta with
* transient cache rows that look identical to "1:16 not optimized" externally).
*
* Strategy
* --------
* We register four metadata filters that intercept any `_transient_hp_*` /
* `_transient_timeout_hp_*` post meta read/write/delete and re-route the
* call to wp_options via the native `set_transient` storage layout. HivePress
* remains oblivious — its standard `update_post_meta` / `get_post_meta` /
* `delete_post_meta` calls work transparently — but the actual storage moves
* out of wp_postmeta entirely.
*
* Translation rule
* ----------------
* wp_postmeta(post_id, '_transient_hp_models/cat/v1', $val)
* → wp_options('_transient_<HASH>', $val)
*
* wp_postmeta(post_id, '_transient_timeout_hp_models/cat/v1', $exp)
* → wp_options('_transient_timeout_<HASH>', $exp)
*
* where HASH = "wpdo_hp_pm_{$post_id}_" . md5($stripped_key) — fits within
* the 172-char wp_options.option_name index, namespaces by post_id (so two
* listings caching different models never collide), and is deterministic so
* read/write/delete all hit the same row.
*
* Scope (v2.11.5 launch)
* ----------------------
* ✅ wp_postmeta `_transient_hp_*` (HivePress is the dominant offender)
* ❌ wp_usermeta / wp_termmeta / wp_commentmeta (deferred — measure first)
* ❌ Other plugins' `_transient_*` postmeta (deliberately excluded for safety)
*
* Toggle
* ------
* Option `wpdo_hp_transient_filter_enabled` (default true). Disable via:
* `wp option update wpdo_hp_transient_filter_enabled 0`
*
* @package WP_Data_Optimizer
* @since 2.11.5
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Routes HivePress per-post TTL cache out of wp_postmeta into wp_options.
*/
final class TMDO_Hivepress_Transient_Filter {
/** Option toggle key. */
public const OPT_ENABLED = 'wpdo_hp_transient_filter_enabled';
/** Prefix that identifies a HivePress meta-cache value row. */
public const PREFIX_VALUE = '_transient_hp_';
/** Prefix that identifies a HivePress meta-cache timeout row. */
public const PREFIX_TIMEOUT = '_transient_timeout_hp_';
/** Namespace prefix for translated wp_options entries. */
public const TRANSLATED_NAMESPACE = 'wpdo_hp_pm_';
/**
* Register the four metadata filters on `init`. Idempotent — safe to call
* multiple times.
*
* @return void
*/
public static function init(): void {
if ( ! self::is_enabled() ) {
return;
}
// Filter priority 9 — runs *before* WPDO Hook Bus (priority 10) so we
// strip transient keys from the meta path before Hook Bus attempts to
// route them through entity registry (it wouldn't find a match anyway,
// but skipping the lookup is a tiny perf win).
add_filter( 'get_post_metadata', array( self::class, 'on_read' ), 9, 4 );
add_filter( 'add_post_metadata', array( self::class, 'on_add' ), 9, 5 );
add_filter( 'update_post_metadata', array( self::class, 'on_update' ), 9, 5 );
add_filter( 'delete_post_metadata', array( self::class, 'on_delete' ), 9, 5 );
}
/**
* Check the admin toggle. Defaults to enabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return (bool) get_option( self::OPT_ENABLED, '1' );
}
/**
* Test whether a meta_key is a HivePress transient pair (value or timeout).
*
* @param mixed $meta_key Candidate meta_key (may be non-string from filter).
* @return bool
*/
public static function is_target_key( $meta_key ): bool {
if ( ! is_string( $meta_key ) ) {
return false;
}
return str_starts_with( $meta_key, self::PREFIX_VALUE )
|| str_starts_with( $meta_key, self::PREFIX_TIMEOUT );
}
/**
* Translate (post_id, meta_key) → namespaced wp_options option_name root.
*
* The original meta_key is hashed (md5) so very long HivePress cache keys
* (`_transient_hp_models/term/listing_availability/<long-hex-hash>`) still
* fit within wp_options.option_name index width.
*
* @param int $post_id Post ID owning the cache.
* @param string $meta_key Original `_transient_*` meta_key.
* @return string Translated option_name root (without `_transient_` prefix).
*/
public static function translate_key( int $post_id, string $meta_key ): string {
// Strip `_transient_timeout_` or `_transient_` prefix to get the bare cache name.
$stripped = preg_replace( '/^_transient_(timeout_)?/', '', $meta_key );
return self::TRANSLATED_NAMESPACE . $post_id . '_' . md5( (string) $stripped );
}
/**
* Filter callback: get_post_metadata.
*
* Returns null → continue normal flow (DB query); returns array → WP
* unwraps `[0]` for `$single=true` callers, or returns array as-is for
* `$single=false` callers.
*
* @param mixed $pre Filter accumulator (null at our priority).
* @param int $object_id Post ID.
* @param string $meta_key Meta key being read.
* @param bool $single Whether single value was requested.
* @return mixed
*/
public static function on_read( $pre, $object_id, $meta_key, $single ) {
if ( ! self::is_target_key( $meta_key ) ) {
return $pre;
}
$is_timeout = str_starts_with( (string) $meta_key, self::PREFIX_TIMEOUT );
$translated = self::translate_key( (int) $object_id, (string) $meta_key );
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
// Read directly from wp_options without going through get_transient()
// — we don't want the transient API to delete-on-expire, because the
// HivePress cache layer reads the timeout *first*, then decides whether
// to read the value. We must preserve raw stored values until HivePress
// itself orders deletion.
$value = get_option( $option_name, null );
if ( null === $value ) {
// Cache miss in our store → fall through to normal postmeta path
// (back-compat: lets a pre-filter postmeta row still resolve).
return $pre;
}
// Match WP's metadata return contract:
// $single=true returns [value] (WP unwraps to value),
// $single=false returns array of values (WP returns as-is).
return array( $value );
}
/**
* Filter callback: add_post_metadata.
*
* Returns non-null to short-circuit the DB INSERT; truthy result is what
* `add_post_meta()` returns to its caller (typically a meta_id, but a
* truthy non-zero is sufficient for the calling code's success check).
*
* @param mixed $check Filter accumulator (null at our priority).
* @param int $object_id Post ID.
* @param string $meta_key Meta key being written.
* @param mixed $meta_value Value being written.
* @param bool $unique Whether the unique flag was set.
* @return mixed
*/
public static function on_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
unset( $unique ); // HivePress transient writes never use $unique.
if ( ! self::is_target_key( $meta_key ) ) {
return $check;
}
self::write_translated( (int) $object_id, (string) $meta_key, $meta_value );
return true;
}
/**
* Filter callback: update_post_metadata.
*
* @param mixed $check Filter accumulator (null at our priority).
* @param int $object_id Post ID.
* @param string $meta_key Meta key being written.
* @param mixed $meta_value Value being written.
* @param mixed $prev_value Previous value scope (unused for our keys).
* @return mixed
*/
public static function on_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
unset( $prev_value );
if ( ! self::is_target_key( $meta_key ) ) {
return $check;
}
self::write_translated( (int) $object_id, (string) $meta_key, $meta_value );
return true;
}
/**
* Filter callback: delete_post_metadata.
*
* @param mixed $check Filter accumulator (null at our priority).
* @param int $object_id Post ID.
* @param string $meta_key Meta key being deleted.
* @param mixed $meta_value Value-scoped delete (unused for our keys).
* @param bool $delete_all Whether to delete from all objects (unused).
* @return mixed
*/
public static function on_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
unset( $meta_value, $delete_all );
if ( ! self::is_target_key( $meta_key ) ) {
return $check;
}
$is_timeout = str_starts_with( (string) $meta_key, self::PREFIX_TIMEOUT );
$translated = self::translate_key( (int) $object_id, (string) $meta_key );
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
delete_option( $option_name );
return true;
}
/**
* Internal: persist value or timeout 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).
*
* @param int $post_id Post ID.
* @param string $meta_key Original `_transient_*` meta_key.
* @param mixed $meta_value Value to store.
* @return void
*/
private static function write_translated( int $post_id, string $meta_key, $meta_value ): void {
$is_timeout = str_starts_with( $meta_key, self::PREFIX_TIMEOUT );
$translated = self::translate_key( $post_id, $meta_key );
$option_name = ( $is_timeout ? '_transient_timeout_' : '_transient_' ) . $translated;
update_option( $option_name, $meta_value, false );
}
/**
* One-time migration helper: count how many `_transient_hp_*` rows
* currently live in wp_postmeta (callers can use this to decide whether
* to run the cleanup CLI).
*
* @return int
*/
public static function count_legacy_postmeta_rows(): int {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->postmeta}
WHERE meta_key LIKE '\\_transient\\_hp\\_%'
OR meta_key LIKE '\\_transient\\_timeout\\_hp\\_%'"
);
// phpcs:enable
}
/**
* One-time migration: DELETE all historical `_transient_hp_*` rows from
* wp_postmeta. Safe to call when the filter is enabled (any future writes
* will go to wp_options instead).
*
* Note: existing transient values are *abandoned* (HivePress will rebuild
* them on first cache miss). We don't attempt to migrate values to
* wp_options because:
* - HivePress's cache versioning means stale values are auto-superseded
* - The cost of re-fetching is bounded (taxonomy term lookups, fast)
*
* @return int Rows deleted.
*/
public static function purge_legacy_postmeta_rows(): int {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
return (int) $wpdb->query(
"DELETE FROM {$wpdb->postmeta}
WHERE meta_key LIKE '\\_transient\\_hp\\_%'
OR meta_key LIKE '\\_transient\\_timeout\\_hp\\_%'"
);
// phpcs:enable
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
/**
* Deprecated stub — superseded by `integrations/hivepress/` adapter family.
*
* Pre-v3.0.0 this file held a 242-line monolithic HivePress integration
* (Listing/Vendor query optimizer + addon field auto-detection). The
* integration was rewritten in v3.0.0 as a per-addon adapter family living
* under `includes/integrations/hivepress/`:
*
* - Detector → `class-tmdo-hivepress-detector.php`
* - Bootstrap → `class-tmdo-hivepress-bootstrap.php`
* - Conflict guard → `class-tmdo-hivepress-conflict-guard.php`
* - Adapter trait → `trait-tmdo-hp-adapter.php`
* - Adapter intf. → `interface-tmdo-hp-adapter.php`
* - Core adapter → `adapters/class-tmdo-hivepress-core-adapter.php`
* - Future addons → `adapters/class-tmdo-hivepress-{addon}-adapter.php`
*
* `TMDO_Core::run()` now boots `TMDO_HivePress_Bootstrap::boot()` instead
* of `( new TMDO_HivePress() )->register_hooks()`. The legacy class survives
* here as a deprecated stub so any third-party code that calls
* `new TMDO_HivePress()` doesn't fatal — methods route to the new adapter
* with an `_doing_it_wrong()` notice.
*
* Removal target: v4.0.0.
*
* @package WP_Data_Optimizer
* @since 0.3.0
* @deprecated 3.0.0 Use TMDO_HivePress_Bootstrap + adapters instead.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress' ) ) {
/**
* Deprecated WPDO HivePress integration shim.
*
* @deprecated 3.0.0 Use TMDO_HivePress_Bootstrap + adapter family.
*/
class TMDO_HivePress {
/**
* No-op deprecated shim — bootstrap auto-binds adapters now.
*
* @deprecated 3.0.0
*/
public function register_hooks(): void {
if ( function_exists( '_doing_it_wrong' ) ) {
_doing_it_wrong(
__METHOD__,
'TMDO_HivePress is deprecated. TMDO_HivePress_Bootstrap auto-binds adapters at plugins_loaded:5.',
'3.0.0'
);
}
// No-op: bootstrap already wired the core adapter.
}
/**
* Deprecated shim — delegates to the new core adapter when available.
*
* @deprecated 3.0.0 Use TMDO_HivePress_Core_Adapter::optimize_query().
*
* @param \WP_Query $query Query object.
*/
public function optimize_query( \WP_Query $query ): void {
if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
return;
}
$adapter = class_exists( 'TMDO_HivePress_Bootstrap' )
? TMDO_HivePress_Bootstrap::adapter_for( 'hivepress' )
: null;
if ( $adapter instanceof TMDO_HivePress_Core_Adapter ) {
$adapter->optimize_query( $query );
}
}
/**
* Deprecated shim — delegates to the new core adapter when available.
*
* @deprecated 3.0.0 Use TMDO_HivePress_Core_Adapter::optimize_search().
*
* @param \WP_Query $query Query object.
* @param array $attribute_fields HP attribute fields (unused).
*/
public function optimize_search( \WP_Query $query, array $attribute_fields ): void {
if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
return;
}
$adapter = class_exists( 'TMDO_HivePress_Bootstrap' )
? TMDO_HivePress_Bootstrap::adapter_for( 'hivepress' )
: null;
if ( $adapter instanceof TMDO_HivePress_Core_Adapter ) {
$adapter->optimize_search( $query, $attribute_fields );
}
}
/**
* Deprecated shim — field registration is now per-adapter.
*
* @deprecated 3.0.0 Field registration moved to per-addon adapters.
*
* @param TMDO_Schema_Registry $registry Registry singleton.
*/
public function register_extended_fields( TMDO_Schema_Registry $registry ): void {
unset( $registry );
if ( function_exists( '_doing_it_wrong' ) ) {
_doing_it_wrong(
__METHOD__,
'Field registration moved to per-addon adapters under includes/integrations/hivepress/adapters/.',
'3.0.0'
);
}
}
}
} // end if ( ! class_exists )
+248
View File
@@ -0,0 +1,248 @@
<?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',
};
}
}
+264
View File
@@ -0,0 +1,264 @@
<?php
/**
* Listing stats integration for Zone B view counting.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone B/D integration — Listing Statistics & Archival.
*
* Zone B (Warm): Tracks per-listing page view counts with a 24-hour TTL.
* - Reads from wpdo_warm first; falls back to postmeta if no warm entry.
* - Increments on each front-end singular hp_listing page load (non-admin, non-bot).
* - Keeps postmeta clean by flushing accumulated counts via cron (wpdo_flush_views).
*
* Zone D (Archive): Archives hot-zone fields for expired listings.
* - Triggered by the existing wpdo_archive_sweep cron (daily).
* - Finds listings where hp_expired_time < now and expired > TMDO_ARCHIVE_DAYS ago.
* - Archives hp_price, hp_featured, hp_verified, hp_expired_time with gzip compression.
*/
class TMDO_Listing_Stats {
/** Meta key used for the warm-zone view counter. */
const VIEW_KEY = 'wpdo_views';
/** TTL for warm-zone view entries (24 hours). */
const VIEW_TTL = DAY_IN_SECONDS;
/** How many days after expiry before archiving hot fields. */
const ARCHIVE_DAYS = 30;
/** Hot-zone meta keys to archive for expired listings. */
const ARCHIVE_KEYS = array( 'hp_price', 'hp_featured', 'hp_verified', 'hp_expired_time' );
/**
* Register all hooks.
*/
public static function register_hooks(): void {
// Zone B: increment view count on front-end singular listing pages.
add_action( 'wp', array( __CLASS__, 'maybe_increment_view' ) );
// Zone B: flush accumulated warm-zone counts to postmeta (hourly).
add_action( 'wpdo_flush_views', array( __CLASS__, 'flush_views_to_postmeta' ) );
// Zone D: archive expired listing fields (daily cron via wpdo_archive_sweep).
add_action( 'wpdo_archive_sweep', array( __CLASS__, 'archive_expired_listings' ), 20 );
}
/**
* Schedule the flush cron if not already registered.
*
* Called from TMDO_Core::schedule_cron().
*/
public static function schedule_cron(): void {
if ( ! wp_next_scheduled( 'wpdo_flush_views' ) ) {
wp_schedule_event( time(), 'hourly', 'wpdo_flush_views' );
}
}
// ── Zone B: View Counter ─────────────────────────────────────────────────
/**
* Increment the warm-zone view counter for the current listing page.
*
* Only fires on front-end, singular hp_listing pages, for non-bot requests.
*/
public static function maybe_increment_view(): void {
if ( is_admin() || ! is_singular( 'hp_listing' ) ) {
return;
}
// Skip common bots by checking for empty or known bot user-agents.
$ua = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
if ( empty( $ua ) || preg_match( '/bot|crawl|slurp|spider|mediapartners/i', $ua ) ) {
return;
}
$post_id = (int) get_the_ID();
if ( $post_id <= 0 ) {
return;
}
self::increment_view( $post_id );
}
/**
* Increment the warm-zone view count for a listing.
*
* @param int $post_id Listing post ID.
*/
public static function increment_view( int $post_id ): void {
$current = (int) TMDO_Zone_Warm::get( $post_id, self::VIEW_KEY );
TMDO_Zone_Warm::set( $post_id, self::VIEW_KEY, (string) ( $current + 1 ), self::VIEW_TTL );
}
/**
* Read the view count for a listing.
*
* Returns warm-zone value if available; falls back to postmeta hp_view_count.
*
* @param int $post_id Listing post ID.
* @return int
*/
public static function get_view_count( int $post_id ): int {
$warm = TMDO_Zone_Warm::get( $post_id, self::VIEW_KEY );
if ( null !== $warm ) {
return (int) $warm;
}
return (int) get_post_meta( $post_id, 'hp_view_count', true );
}
/**
* Flush all warm-zone view counts to postmeta and reset warm entries.
*
* Called hourly by wpdo_flush_views cron.
*
* @return int Number of listings flushed.
*/
public static function flush_views_to_postmeta(): int {
global $wpdb;
$table = TMDO_Zone_Warm::table();
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_value FROM `{$table}` WHERE meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
self::VIEW_KEY
),
ARRAY_A
);
if ( ! $rows ) {
return 0;
}
// Batch-fetch existing hp_view_count for all post IDs in one query.
$post_ids = array_map( 'intval', array_column( $rows, 'post_id' ) );
$placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
$existing_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = 'hp_view_count' AND post_id IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $wpdb->postmeta is core. phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Listing Stats: Zone B → postmeta view-count sync path.
...$post_ids
),
ARRAY_A
);
$existing_map = array();
foreach ( $existing_rows as $er ) {
$existing_map[ (int) $er['post_id'] ] = (int) $er['meta_value'];
}
$flushed = 0;
foreach ( $rows as $row ) {
$post_id = (int) $row['post_id'];
$new_views = (int) $row['meta_value'];
// Add to existing postmeta total.
$existing = $existing_map[ $post_id ] ?? 0;
update_post_meta( $post_id, 'hp_view_count', $existing + $new_views );
// Remove the warm entry after flush.
TMDO_Zone_Warm::delete( $post_id, self::VIEW_KEY );
++$flushed;
}
return $flushed;
}
// ── Zone D: Expired Listing Archival ─────────────────────────────────────
/**
* Archive hot-zone fields for listings that expired ARCHIVE_DAYS ago.
*
* Reads hp_expired_time from postmeta (or hot zone via Sync Bridge).
* Archives ARCHIVE_KEYS with gzip compression into wpdo_archive.
*
* @param int $limit Max listings to process per run.
* @return int Number of listing fields archived.
*/
public static function archive_expired_listings( int $limit = 200 ): int {
global $wpdb;
$cutoff = (int) ( time() - self::ARCHIVE_DAYS * DAY_IN_SECONDS );
// Find published listings with hp_expired_time before cutoff.
$listings = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT pm.post_id
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = 'hp_expired_time'
AND pm.meta_value > '0'
AND CAST(pm.meta_value AS UNSIGNED) < %d
AND p.post_type = 'hp_listing'
AND p.post_status IN ('publish','private')
LIMIT %d",
$cutoff,
$limit
)
);
if ( ! $listings ) {
return 0;
}
$listing_ids = array_map( 'intval', $listings );
$id_list = implode( ',', $listing_ids );
$key_list = implode( ',', array_fill( 0, count( self::ARCHIVE_KEYS ), '%s' ) );
// Batch-fetch all relevant postmeta rows in one query.
$meta_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_id, post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id IN ({$id_list}) AND meta_key IN ({$key_list})", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $wpdb->postmeta is core. phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Archive sweep: must read postmeta to migrate to Zone D.
...self::ARCHIVE_KEYS
),
ARRAY_A
);
// Build lookup: $meta_map[ post_id ][ meta_key ] = [ meta_id, meta_value ].
$meta_map = array();
foreach ( $meta_rows as $mr ) {
$meta_map[ (int) $mr['post_id'] ][ $mr['meta_key'] ] = array(
'meta_id' => (int) $mr['meta_id'],
'meta_value' => $mr['meta_value'],
);
}
$archived = 0;
foreach ( $listing_ids as $post_id ) {
foreach ( self::ARCHIVE_KEYS as $meta_key ) {
if ( ! isset( $meta_map[ $post_id ][ $meta_key ] ) ) {
continue;
}
$entry = $meta_map[ $post_id ][ $meta_key ];
$meta_value = $entry['meta_value'];
if ( '' === $meta_value || false === $meta_value ) {
continue;
}
TMDO_Zone_Archive::archive(
$post_id,
'hp_listing',
$meta_key,
(string) $meta_value,
$entry['meta_id'],
true // compress.
);
++$archived;
}
}
return $archived;
}
/**
* Get archive statistics for expired listings.
*
* @return array{total_rows: int, compressed_rows: int, post_types: array}
*/
public static function stats(): array {
return TMDO_Zone_Archive::stats();
}
}
@@ -0,0 +1,73 @@
<?php
/**
* Blocks adapter — `hivepress-blocks`.
*
* Pure presentation layer (Gutenberg block library). No models, no postmeta,
* no comment tables. Adapter exists solely so the detector and admin UI
* recognise the addon as installed and report it in the integration matrix.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Blocks_Adapter' ) ) {
/**
* Blocks addon adapter (presentation only, no EAV surface).
*/
final class TMDO_HivePress_Blocks_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/**
* Constructor — bind register hooks via inherited trait.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-blocks';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\Blocks\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_BLOCKS_VERSION';
}
/** Minimum addon version supported. */
public function minimum_addon_version(): string {
return '1.0.0';
}
/**
* Doctor probe — presentation-only, nothing to verify.
*
* @return array{ok:bool, message:string}
*/
public function doctor_check(): array {
return array(
'ok' => true,
'message' => 'hivepress-blocks: presentation-only addon, no anti-EAV surface',
);
}
/**
* 8-D self-score — perfect (no data layer).
*/
public function suitability_score(): array {
return $this->compose_score( array() );
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,159 @@
<?php
/**
* Bookings adapter — `hivepress-bookings` (DETECT-ONLY in v3.0.0).
*
* The bookings plugin is not installed in the dev environment, so this
* adapter ships pre-built but only activates when the addon is detected
* by `TMDO_HivePress_Detector`. Schema is based on HivePress' published
* 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)
* 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.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Bookings_Adapter' ) ) {
/**
* Bookings addon adapter (detect-only — schema reserved for future install).
*/
final class TMDO_HivePress_Bookings_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/**
* Constructor — bind register hooks via inherited trait.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-bookings';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\Bookings\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_BOOKINGS_VERSION';
}
/** Minimum addon version supported. */
public function minimum_addon_version(): string {
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.
*
* D8 (perf) reduced to 0.9 because the schema is best-effort from
* documentation; once the addon is installed and EXPLAIN can verify
* index usage, raise to 1.0.
*/
public function suitability_score(): array {
return $this->compose_score(
array(
'd8_perf' => 0.9,
)
);
}
/**
* FSM modules this adapter contributes.
*
* @return array<int,string>
*/
public function migrations(): array {
return array( 'hot_hp_booking' );
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,407 @@
<?php
/**
* Core HivePress adapter — `hivepress` (the parent plugin).
*
* Owns three 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`.
*
* This adapter SUPERSEDES the legacy `class-tmdo-hivepress.php` (242 lines).
* Behavioural parity:
*
* - `optimize_query()` / `optimize_search()` — preserved as instance
* methods bound on `hivepress/v1/models/{listing,vendor}/{query,search}`
* at priority 20 (after HP own setup).
* - `register_extended_fields()` — preserved as `on_register_fields()`
* handler. Geolocation / Bookings / Marketplace addon detection is
* hoisted to the dedicated adapter files in Sprint 2/3; this core
* 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).
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
/**
* Core HivePress adapter (Listing / Vendor / User).
*/
final class TMDO_HivePress_Core_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/**
* HP models that have Zone A hot tables. Used by query optimizer.
*/
private const HOT_MODELS = array(
'listing' => 'hp_listing',
'vendor' => 'hp_vendor',
);
/**
* Constructor — bind WPDO core hooks via the inherited trait sugar.
*
* The bootstrap calls `on_register_query_hooks()` / `on_register_event_hooks()`
* directly after instantiation, but field/table registration goes
* through WordPress's action dispatch via this trait helper.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
// ── Adapter identity ───────────────────────────────────────────────
/**
* Stable adapter slug (matches the hivepress core wp.org slug).
*/
public function plugin_slug(): string {
return 'hivepress';
}
/**
* Class probed for HivePress core presence.
*/
public function detection_class(): string {
return 'HivePress\\Core';
}
/**
* Version constant probed for HivePress core presence.
*/
public function detection_const(): string {
return 'HIVEPRESS_VERSION';
}
/**
* Minimum HivePress core version this adapter supports.
*
* Version 1.7.0 introduced the `_alias` / `_external` field convention
* this adapter relies on. Older versions store fields differently and
* would require fallback paths not implemented here.
*/
public function minimum_addon_version(): string {
return '1.7.0';
}
// ── Field registration ─────────────────────────────────────────────
/**
* Register Listing/Vendor zone field mappings.
*
* Fired on the `wpdo_register_fields` action via the trait's
* `bind_anti_eav_hooks()`. Schema_Registry has internal dedup so
* re-firing on init:1 is safe.
*
* @param TMDO_Schema_Registry $registry Schema registry singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
// ── Listing hot fields (search/filter accelerators) ──
$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',
'meta_key' => 'hp_image',
'zone' => 'cold',
'cache_group' => 'wpdo_cold_hp_vendor',
'cache_ttl' => HOUR_IN_SECONDS,
),
// Listing cold blob — gallery (display-only).
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_images',
'zone' => 'cold',
'cache_group' => 'wpdo_cold_hp_listing',
'cache_ttl' => HOUR_IN_SECONDS,
),
)
);
}
/**
* 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).
*/
public function on_register_query_hooks(): void {
foreach ( self::HOT_MODELS as $model => $post_type ) {
unset( $post_type ); // Bound below by string-interpolated $model only.
add_action(
"hivepress/v1/models/{$model}/query",
array( $this, 'optimize_query' ),
20,
1
);
add_action(
"hivepress/v1/models/{$model}/search",
array( $this, 'optimize_search' ),
20,
2
);
}
}
/**
* Optimize a HivePress model query by extracting hot-zone meta_query clauses.
*
* Extracted clauses are stashed on the WP_Query as `wpdo_hot_clauses`;
* `TMDO_Query_Router` rewrites them into flat-column JOINs at
* `posts_join` time.
*
* @param \WP_Query $query The WP_Query object.
*/
public function optimize_query( \WP_Query $query ): void {
$post_types = (array) $query->get( 'post_type' );
if ( empty( $post_types ) ) {
return;
}
$existing_hot = $query->get( 'wpdo_hot_clauses' );
if ( ! empty( $existing_hot ) ) {
return;
}
$meta_query = (array) $query->get( 'meta_query' );
if ( empty( $meta_query ) ) {
return;
}
$registry = TMDO_Schema_Registry::instance();
$hot_clauses = array();
$remaining = array();
foreach ( $meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
$remaining[ $k ] = $clause;
continue;
}
$matched = false;
foreach ( $post_types as $pt ) {
$field = $registry->get_field( $pt, $clause['key'] );
if ( $field && 'hot' === $field['zone'] ) {
$module = 'hot_' . sanitize_key( $pt );
if ( class_exists( 'TMDO_Feature_Flags' ) && ! TMDO_Feature_Flags::is_query_active( $module ) ) {
continue;
}
$hot_clauses[ $pt ][] = array(
'column' => $field['column'],
'value' => $clause['value'] ?? '',
'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ),
'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ),
);
$matched = true;
break;
}
}
if ( ! $matched ) {
$remaining[ $k ] = $clause;
}
}
if ( empty( $hot_clauses ) ) {
return;
}
if ( isset( $meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
$remaining['relation'] = $meta_query['relation'];
}
$query->set( 'meta_query', $remaining );
$query->set( 'wpdo_hot_clauses', $hot_clauses );
}
/**
* Optimize a HivePress search query (delegates to optimize_query).
*
* Bound to `hivepress/v1/models/{model}/search` which passes attribute
* fields as second arg; we ignore them (extracted via meta_query).
*
* @param \WP_Query $query The WP_Query object.
* @param array $attribute_fields HP attribute field objects (unused).
*/
public function optimize_search( \WP_Query $query, array $attribute_fields ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
unset( $attribute_fields );
$this->optimize_query( $query );
}
// ── 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.
*
* Default 1.0 across the board because:
* - D1: only TMDO_API + Schema_Registry calls (no direct *_meta())
* - D2: no hardcoded wp_postmeta literals
* - D3: hot tables for both Listing + Vendor exist
* - D4: every field registered with explicit data_type + indexed flag
* - D5: no cross-adapter writes needed (single-adapter scope)
* - D6: zero options written; no transients (relies on WPDO core caching)
* - D7: no queries against other adapters' tables
* - D8: hot columns indexed on every search-relevant field
*/
public function suitability_score(): array {
return $this->compose_score(
array(
// D5 is N/A for a single-adapter; report 1.0 (no cross-write needed).
'd5_hook_bus' => 1.0,
)
);
}
/**
* 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 )
@@ -0,0 +1,234 @@
<?php
/**
* Favorites adapter — `hivepress-favorites`.
*
* The Favorite model extends Comment (`comment_type = hp_favorite`):
*
* - user → user_id
* - 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)
* );
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) {
/**
* Favorites addon adapter.
*/
final class TMDO_HivePress_Favorites_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/** Comment_type owned by this adapter. */
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.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-favorites';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\Favorites\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_FAVORITES_VERSION';
}
/** Minimum addon version supported. */
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 )
@@ -0,0 +1,135 @@
<?php
/**
* Marketplace adapter — `hivepress-marketplace` (DETECT-ONLY in v3.0.0).
*
* The marketplace addon turns listings into purchasable items, integrating
* with WooCommerce. It adds:
*
* 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.
*
* Detect-only: not installed in dev environment. Schema reserved for the
* day the addon ships.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Marketplace_Adapter' ) ) {
/**
* Marketplace addon adapter (detect-only).
*/
final class TMDO_HivePress_Marketplace_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/**
* Constructor — bind register hooks via inherited trait.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-marketplace';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\Marketplace\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_MARKETPLACE_VERSION';
}
/** Minimum addon version supported. */
public function minimum_addon_version(): string {
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.
*
* D8 reduced to 0.9 — index assumption is best-effort until addon installs.
*/
public function suitability_score(): array {
return $this->compose_score(
array(
'd8_perf' => 0.9,
)
);
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,169 @@
<?php
/**
* Memberships adapter — `hivepress-memberships`.
*
* Two post-type models:
*
* - Membership_Plan (post_type=hp_membership_plan)
* hot fields: hp_expire_period (int), hp_primary (bool)
*
* - Membership (post_type=hp_membership)
* hot fields: 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.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Memberships_Adapter' ) ) {
/**
* Memberships addon adapter.
*/
final class TMDO_HivePress_Memberships_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/**
* Constructor — bind register hooks via inherited trait.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-memberships';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\Memberships\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_MEMBERSHIPS_VERSION';
}
/** Minimum addon version supported. */
public function minimum_addon_version(): string {
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.
*
* D1: 1.0 — adapter never calls *_meta()
* D2: 1.0 — uses wpdb prefix
* D3: 1.0 — both models have hot tables
* D4: 1.0 — every field has explicit data_type + indexed flag
* D5: 1.0 — single-adapter scope
* D6: 1.0 — zero options/transients
* D7: 1.0 — only registers fields, no cross-table queries
* D8: 1.0 — hot column on hp_expired_time = indexed range scan
*/
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 )
@@ -0,0 +1,263 @@
<?php
/**
* 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.
*
* 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)
* );
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Messages_Adapter' ) ) {
/**
* Messages addon adapter.
*/
final class TMDO_HivePress_Messages_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/** Comment_type owned by this adapter. */
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.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-messages';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\Messages\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_MESSAGES_VERSION';
}
/** Minimum addon version supported. */
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 )
@@ -0,0 +1,267 @@
<?php
/**
* Requests adapter — `hivepress-requests`.
*
* 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)
*
* - 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)
* );
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Requests_Adapter' ) ) {
/**
* Requests addon adapter.
*/
final class TMDO_HivePress_Requests_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/** 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.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-requests';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\Requests\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_REQUESTS_VERSION';
}
/** Minimum addon version supported. */
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 )
@@ -0,0 +1,146 @@
<?php
/**
* Reviews adapter — `hivepress-reviews`.
*
* The Review model extends `Comment` (`comment_type = hp_review`) so it
* stores everything in `wp_comments` columns:
*
* - text → comment_content
* - rating → comment_karma (1-5 integer)
* - 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).
*
* 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
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Reviews_Adapter' ) ) {
/**
* Reviews addon adapter.
*/
final class TMDO_HivePress_Reviews_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/** Comment_type owned by this adapter. */
public const COMMENT_TYPE = 'hp_review';
/** Entity-bridge table used by TMDO_Adapter_Comment for hp_review. */
private const TABLE = 'wpdo_comment_hp_review';
/**
* Constructor — bind WPDO core register hooks via inherited trait.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-reviews';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\Reviews\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_REVIEWS_VERSION';
}
/** Minimum addon version supported. */
public function minimum_addon_version(): string {
return '1.4.0';
}
/**
* Doctor probe — verify entity-bridge table exists and is reachable.
*
* @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-reviews: %s missing (Comment entity-bridge not migrated)', $table ),
'details' => array( 'table' => $table ),
);
}
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Sanitized via wpdb prefix.
return array(
'ok' => true,
'message' => sprintf( 'hivepress-reviews: %s rows=%d', $table, $rows ),
'details' => array(
'table' => $table,
'rows' => $rows,
),
);
} catch ( \Throwable $e ) {
return array(
'ok' => false,
'message' => sprintf( 'hivepress-reviews doctor failed: %s', $e->getMessage() ),
);
}
}
/**
* 8-D self-score.
*
* D1: 1.0 — adapter never calls *_meta() (Review is comment-column based)
* D2: 1.0 — no hardcoded wp_comments references; uses wpdb prefix
* D3: 1.0 — entity-bridge table covers the model
* D4: 1.0 — table schema owned by Comment entity adapter (already documented)
* 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
*/
public function suitability_score(): array {
return $this->compose_score( array() );
}
/**
* FSM modules contributed (none — comment entity-bridge owns lifecycle).
*
* @return array<int,string>
*/
public function migrations(): array {
return array(
// Comment entity-bridge migration is module 'comment_hp_review',
// owned by TMDO_Migration_Orchestrator — adapter is read-only.
);
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,76 @@
<?php
/**
* SEO adapter — `hivepress-seo`.
*
* The SEO addon is filter-based — it tweaks listing/vendor page titles,
* meta descriptions, OpenGraph tags. No models of its own, no postmeta
* additions beyond what HivePress core already manages.
*
* Adapter contributes only detection signal + readiness report. Nothing
* to anti-EAV.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Seo_Adapter' ) ) {
/**
* SEO addon adapter (filter-based, no EAV surface).
*/
final class TMDO_HivePress_Seo_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/**
* Constructor — bind register hooks via inherited trait.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-seo';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\Seo\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_SEO_VERSION';
}
/** Minimum addon version supported. */
public function minimum_addon_version(): string {
return '1.0.0';
}
/**
* Doctor probe — filter-based addon has nothing to verify.
*
* @return array{ok:bool, message:string}
*/
public function doctor_check(): array {
return array(
'ok' => true,
'message' => 'hivepress-seo: filter-based addon, no anti-EAV surface',
);
}
/**
* 8-D self-score — perfect 10 (no EAV usage to optimize).
*/
public function suitability_score(): array {
return $this->compose_score( array() );
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,115 @@
<?php
/**
* 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.
*
* 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.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Social_Links_Adapter' ) ) {
/**
* Social Links addon adapter (vendor cold zone).
*/
final class TMDO_HivePress_Social_Links_Adapter implements TMDO_HivePress_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.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-social-links';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\SocialLinks\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_SOCIAL_LINKS_VERSION';
}
/** Minimum addon version supported. */
public function minimum_addon_version(): string {
return '1.0.0';
}
/**
* Register vendor social URL fields to 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 ) {
$fields[] = array(
'post_type' => 'hp_vendor',
'meta_key' => $key,
'zone' => 'cold',
'cache_group' => 'wpdo_cold_hp_vendor',
'cache_ttl' => HOUR_IN_SECONDS,
);
}
$registry->register_many( $this->plugin_slug(), $fields );
}
/**
* Doctor probe — cold zone uses cache; verify cache layer is reachable.
*
* @return array{ok:bool, message:string}
*/
public function doctor_check(): array {
return array(
'ok' => true,
'message' => sprintf( 'hivepress-social-links: %d cold fields registered', count( self::SOCIAL_KEYS ) ),
);
}
/**
* 8-D self-score.
*/
public function suitability_score(): array {
return $this->compose_score( array() );
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,130 @@
<?php
/**
* Statistics adapter — `hivepress-statistics` (DETECT-ONLY in v3.0.0).
*
* The statistics addon tracks listing view counts, click-throughs, and other
* counter-style metrics. The natural zone for these is WARM:
*
* - Increment-heavy writes (every page view → counter++).
* - Read-rarely (only the vendor dashboard cares).
* - Tolerable to lose seconds of writes (TTL-style flush).
*
* Mapped meta keys (best-effort from addon docs):
*
* hp_view_count → warm (cron flush hourly)
* hp_click_count → warm (cron flush hourly)
*
* Detect-only: not installed in dev environment.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Statistics_Adapter' ) ) {
/**
* Statistics addon adapter (detect-only, warm zone).
*/
final class TMDO_HivePress_Statistics_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/**
* Constructor — bind register hooks via inherited trait.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-statistics';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\Statistics\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_STATISTICS_VERSION';
}
/** Minimum addon version supported. */
public function minimum_addon_version(): string {
return '1.0.0';
}
/**
* Register counter fields to warm zone.
*
* @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_view_count',
'zone' => 'warm',
'ttl' => null,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_click_count',
'zone' => 'warm',
'ttl' => null,
),
)
);
}
/**
* Doctor probe — warm zone uses shared wp_wpdo_warm KV 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_warm';
try {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
return array(
'ok' => $exists,
'message' => $exists
? 'hivepress-statistics: warm zone (wpdo_warm) reachable'
: 'hivepress-statistics: wpdo_warm missing',
);
} catch ( \Throwable $e ) {
return array(
'ok' => false,
'message' => sprintf( 'hivepress-statistics doctor failed: %s', $e->getMessage() ),
);
}
}
/**
* 8-D self-score — D8 reduced (warm zone is best-effort until addon installs).
*/
public function suitability_score(): array {
return $this->compose_score(
array(
'd8_perf' => 0.9,
)
);
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,84 @@
<?php
/**
* Tags adapter — `hivepress-tags`.
*
* Adds the `hp_listing_tag` taxonomy to listings. Term-based — no postmeta,
* no hot fields. Term assignment lives in `wp_term_relationships` which is
* already indexed by WordPress core.
*
* Adapter contributes:
* - Detection signal (so admin UI / scorer can see this addon is active)
* - Self-score (perfect because there's nothing to anti-EAV — the addon
* uses term tables, not meta)
* - Doctor probe (no-op success — no custom tables to verify)
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Tags_Adapter' ) ) {
/**
* Tags addon adapter (term-based, no EAV surface).
*/
final class TMDO_HivePress_Tags_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait;
/**
* Constructor — bind register hooks via inherited trait.
*/
public function __construct() {
$this->bind_anti_eav_hooks();
}
/** Adapter slug (matches wp.org plugin slug). */
public function plugin_slug(): string {
return 'hivepress-tags';
}
/** Class probed for addon presence. */
public function detection_class(): string {
return 'HivePress\\Tags\\Plugin';
}
/** Version constant probed for addon presence. */
public function detection_const(): string {
return 'HIVEPRESS_TAGS_VERSION';
}
/** Minimum addon version supported. */
public function minimum_addon_version(): string {
return '1.1.0';
}
/**
* Doctor probe — term-based addon has no custom tables to verify.
*
* @return array{ok:bool, message:string}
*/
public function doctor_check(): array {
return array(
'ok' => true,
'message' => 'hivepress-tags: term-based addon, no anti-EAV surface',
);
}
/**
* 8-D self-score.
*
* All dimensions 1.0 because the addon uses term tables (not meta) —
* there is nothing to anti-EAV. The addon is structurally correct
* by construction, so the adapter has nothing to do beyond declare
* ownership for tooling.
*/
public function suitability_score(): array {
return $this->compose_score( array() );
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,174 @@
<?php
/**
* HivePress dynamic attribute bridge.
*
* HivePress lets users define custom listing/vendor attributes through the
* admin UI ("Listings → Attributes"). Each attribute creates a postmeta key
* shaped like `hp_listing_{slug}` with type info attached. When the
* attribute is marked `filterable=true` AND `searchable=true` it appears in
* archive search forms — and produces meta_query slowness identical to the
* pre-Sprint-1 hp_featured / hp_drafted patterns.
*
* This bridge introspects HivePress's attribute registry and reports which
* dynamic attributes are PROMOTION CANDIDATES — i.e. would benefit from
* being moved to a hot column. It does NOT alter database schema; that's
* destructive and belongs in a CLI tool (Sprint 4 will add
* `wp wpdo hivepress promote-attribute <slug>`).
*
* Per Karpathy guideline: surface tradeoffs, don't auto-promote.
*
* 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'], ...]
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Attribute_Bridge' ) ) {
/**
* Discovers HivePress dynamic attributes that would benefit from hot promotion.
*/
final class TMDO_HivePress_Attribute_Bridge {
/**
* Models whose attributes we inspect.
*
* @var array<int,string>
*/
private const SUPPORTED_MODELS = array( 'listing', 'vendor' );
/**
* Discover promotion candidates across all supported HP models.
*
* Returns one record per attribute that meets the promotion bar:
* `filterable=true` (would appear in URL filters) OR `sortable=true`
* (would feed orderby).
*
* @return array<int,array{model:string, slug:string, meta_key:string, type:string, filterable:bool, sortable:bool, searchable:bool}>
*/
public function discover_attributes(): array {
$out = array();
foreach ( self::SUPPORTED_MODELS as $model ) {
$attributes = $this->fetch_attributes_for( $model );
foreach ( $attributes as $slug => $config ) {
$record = $this->build_record( $model, (string) $slug, (array) $config );
if ( $this->is_promotion_candidate( $record ) ) {
$out[] = $record;
}
}
}
return $out;
}
/**
* Map of attribute records to suggested zone field configs (does NOT register).
*
* Use this output as input to a CLI promotion tool — it will pass each
* config to `TMDO_Schema_Registry::register()` and trigger schema migration.
*
* @return array<int,array<string,mixed>> Field config records.
*/
public function suggest_zone_configs(): array {
$configs = array();
foreach ( $this->discover_attributes() as $record ) {
$configs[] = array(
'post_type' => 'hp_' . $record['model'],
'meta_key' => $record['meta_key'],
'zone' => 'hot',
'data_type' => $this->sql_type_for( $record['type'] ),
'column' => sanitize_key( $record['meta_key'] ),
'indexed' => $record['filterable'] || $record['sortable'],
);
}
return $configs;
}
// ── Internal helpers ────────────────────────────────────────────────
/**
* Pull the attribute definitions registered for a given HP model.
*
* Goes through HivePress' own filter so any third-party attribute
* registrations are included. Returns empty when HivePress is not
* loaded (defensive — bridge can run during introspection tests).
*
* @param string $model 'listing' or 'vendor'.
* @return array<string,mixed> Slug => attribute config.
*/
private function fetch_attributes_for( string $model ): array {
if ( ! function_exists( 'apply_filters' ) ) {
return array();
}
$filter = sprintf( 'hivepress/v1/models/%s/attributes', $model );
$attrs = apply_filters( $filter, array() );
return is_array( $attrs ) ? $attrs : array();
}
/**
* Normalise one attribute config into our record shape.
*
* @param string $model HP model name.
* @param string $slug Attribute slug.
* @param array<string,mixed> $cfg Raw attribute config from HivePress.
* @return array{model:string, slug:string, meta_key:string, type:string, filterable:bool, sortable:bool, searchable:bool}
*/
private function build_record( string $model, string $slug, array $cfg ): array {
return array(
'model' => $model,
'slug' => $slug,
'meta_key' => sprintf( 'hp_%s_%s', $model, $slug ),
'type' => (string) ( $cfg['edit_field']['type'] ?? $cfg['search_field']['type'] ?? 'text' ),
'filterable' => (bool) ( $cfg['filterable'] ?? false ),
'sortable' => (bool) ( $cfg['sortable'] ?? false ),
'searchable' => (bool) ( $cfg['searchable'] ?? false ),
);
}
/**
* Whether an attribute is worth promoting to a hot column.
*
* @param array{model:string, slug:string, meta_key:string, type:string, filterable:bool, sortable:bool, searchable:bool} $record Attribute record.
*/
private function is_promotion_candidate( array $record ): bool {
// Filterable attributes always go through meta_query in archives.
// Sortable attributes go through `meta_key` in orderby.
// Either case = postmeta hot path → worth promoting.
return $record['filterable'] || $record['sortable'];
}
/**
* Map HivePress field type → SQL column type for hot zone.
*
* Conservative defaults; over-sizing is safer than under-sizing.
*
* @param string $type HivePress field type.
*/
private function sql_type_for( string $type ): string {
switch ( $type ) {
case 'number':
case 'integer':
return 'int(11) NOT NULL DEFAULT 0';
case 'decimal':
case 'price':
return 'decimal(10,2) DEFAULT NULL';
case 'checkbox':
case 'boolean':
return 'tinyint(1) NOT NULL DEFAULT 0';
case 'date':
return 'date DEFAULT NULL';
case 'text':
case 'select':
default:
return "varchar(191) NOT NULL DEFAULT ''";
}
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,128 @@
<?php
/**
* HivePress hot-path benchmark — minimal before/after timing wrapper.
*
* Runs each provided sample query N times against a baseline (postmeta) and
* a target (hot zone / shadow table) and returns elapsed times + ratio. No
* fancy statistics — just `microtime(true)` deltas, mean, ratio.
*
* The CLI command (Sprint 4) will pass real production-like queries; for
* unit tests we exercise the timing harness with synthetic callables that
* sleep deterministically.
*
* Per Karpathy guideline: a stopwatch, not a profiler. Detailed trace
* exists in `wpdo_benchmarks` table when callers want history.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Benchmark' ) ) {
/**
* Minimal benchmark harness.
*/
final class TMDO_HivePress_Benchmark {
/** Default iteration count per sample. */
public const DEFAULT_ITERATIONS = 50;
/**
* Run a single sample comparison.
*
* Each callable is invoked $iterations times. Returns mean ms for
* baseline, mean ms for target, and the speedup ratio (baseline/target).
*
* @param string $name Sample name (e.g. 'listing_search_5_filters').
* @param callable $baseline Callable representing the postmeta path.
* @param callable $target Callable representing the hot-zone path.
* @param int $iterations Iteration count (default 50).
*
* @return array{name:string, baseline_ms:float, target_ms:float, ratio:float, iterations:int}
*/
public static function compare( string $name, callable $baseline, callable $target, int $iterations = self::DEFAULT_ITERATIONS ): array {
$iterations = max( 1, $iterations );
$baseline_ms = self::run( $baseline, $iterations );
$target_ms = self::run( $target, $iterations );
return array(
'name' => $name,
'baseline_ms' => $baseline_ms,
'target_ms' => $target_ms,
'ratio' => $target_ms > 0.0 ? round( $baseline_ms / $target_ms, 2 ) : 0.0,
'iterations' => $iterations,
);
}
/**
* Run a callable N times, return mean elapsed milliseconds.
*
* @param callable $callback Callable under test.
* @param int $iterations Iteration count.
*/
public static function run( callable $callback, int $iterations ): float {
$total = 0.0;
for ( $i = 0; $i < $iterations; $i++ ) {
$start = microtime( true );
$callback();
$total += ( microtime( true ) - $start ) * 1000.0;
}
return round( $total / $iterations, 3 );
}
/**
* Run a sequence of comparisons and produce a summary report.
*
* @param array<int,array{name:string, baseline:callable, target:callable, iterations?:int}> $samples Sample list.
*
* @return array{samples:array<int,array<string,mixed>>, geomean_ratio:float, sample_count:int}
*/
public static function run_suite( array $samples ): array {
$results = array();
$ratios = array();
foreach ( $samples as $sample ) {
if ( ! isset( $sample['name'], $sample['baseline'], $sample['target'] ) ) {
continue;
}
$result = self::compare(
(string) $sample['name'],
$sample['baseline'],
$sample['target'],
(int) ( $sample['iterations'] ?? self::DEFAULT_ITERATIONS )
);
$results[] = $result;
if ( $result['ratio'] > 0.0 ) {
$ratios[] = $result['ratio'];
}
}
return array(
'samples' => $results,
'geomean_ratio' => self::geometric_mean( $ratios ),
'sample_count' => count( $results ),
);
}
/**
* Geometric mean — preferred summary statistic for ratios because it
* doesn't bias toward outliers like arithmetic mean does.
*
* @param array<int,float> $values Ratios.
*/
public static function geometric_mean( array $values ): float {
if ( empty( $values ) ) {
return 0.0;
}
$product = 1.0;
foreach ( $values as $v ) {
$product *= max( 0.0001, (float) $v );
}
return round( pow( $product, 1.0 / count( $values ) ), 2 );
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,242 @@
<?php
/**
* HivePress family integration bootstrap.
*
* Single entry point for the WPDO ↔ HivePress integration:
*
* 1. Run conflict guard (hp-custom-tables / hp-info-cards detection)
* 2. Detect installed HivePress family addons
* 3. For each detected addon, instantiate the matching adapter and bind
* its lifecycle hooks (register_fields / register_custom_tables /
* register_entity_fields / query / event)
* 4. Honour the `wpdo_hivepress_should_bind` filter (conflict-aware)
*
* Booted by `TMDO_Core::run()` once during request boot. Idempotent —
* subsequent calls return early. Order:
*
* plugins_loaded:4 → TMDO_Core::run() → calls Bootstrap::boot()
* plugins_loaded:5+ → adapters' on_register_* hooks fire when WPDO actions dispatch
* init:1 → re-fired by WPDO core safety net
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Bootstrap' ) ) {
/**
* Bootstrap orchestrator for HivePress family adapters.
*
* Stateful only across the current request (instantiated adapters cached
* for `wp wpdo hivepress score/doctor` access). Reset between tests via
* `reset_for_tests()`.
*/
final class TMDO_HivePress_Bootstrap {
/** Slug → adapter class name registry. */
private const ADAPTER_MAP = array(
'hivepress' => 'TMDO_HivePress_Core_Adapter',
'hivepress-reviews' => 'TMDO_HivePress_Reviews_Adapter',
'hivepress-favorites' => 'TMDO_HivePress_Favorites_Adapter',
'hivepress-messages' => 'TMDO_HivePress_Messages_Adapter',
'hivepress-memberships' => 'TMDO_HivePress_Memberships_Adapter',
'hivepress-requests' => 'TMDO_HivePress_Requests_Adapter',
'hivepress-tags' => 'TMDO_HivePress_Tags_Adapter',
'hivepress-seo' => 'TMDO_HivePress_Seo_Adapter',
'hivepress-social-links' => 'TMDO_HivePress_Social_Links_Adapter',
'hivepress-blocks' => 'TMDO_HivePress_Blocks_Adapter',
'hivepress-bookings' => 'TMDO_HivePress_Bookings_Adapter',
'hivepress-marketplace' => 'TMDO_HivePress_Marketplace_Adapter',
'hivepress-statistics' => 'TMDO_HivePress_Statistics_Adapter',
);
/**
* Whether boot() has already run this request.
*
* @var bool
*/
private static bool $booted = false;
/**
* Instantiated adapters keyed by addon slug.
*
* @var array<string, TMDO_HivePress_Adapter>
*/
private static array $adapters = array();
/**
* Detection snapshot at boot time (captured for diagnostics).
*
* @var array<string,string>
*/
private static array $detected = array();
/**
* Boot the integration.
*
* @return void
*/
public static function boot(): void {
if ( self::$booted ) {
return;
}
self::$booted = true;
// Step 1: conflict guard. Detection + admin_notice + filter install.
if ( class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
TMDO_HivePress_Conflict_Guard::check_and_warn();
}
// Step 2: detect addons. Empty array on non-HP sites = zero overhead.
self::$detected = class_exists( 'TMDO_HivePress_Detector' )
? TMDO_HivePress_Detector::detect()
: array();
if ( empty( self::$detected ) ) {
return;
}
// Step 3: respect the should_bind filter (conflict guard short-circuits here).
$context = array(
'detected' => self::$detected,
'adapters' => self::ADAPTER_MAP,
'conflicts' => class_exists( 'TMDO_HivePress_Conflict_Guard' )
? TMDO_HivePress_Conflict_Guard::detect_conflicts()
: array(),
);
$should = apply_filters( 'wpdo_hivepress_should_bind', true, $context );
if ( ! $should ) {
return;
}
// Step 4: instantiate + bind each adapter.
foreach ( self::$detected as $slug => $version ) {
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();
}
if ( class_exists( 'TMDO_HivePress_Cron_Optimizer' ) ) {
TMDO_HivePress_Cron_Optimizer::register();
}
/**
* Action: wpdo_hivepress_booted
*
* Fires after all adapters are bound. Listeners can introspect
* `TMDO_HivePress_Bootstrap::adapters()` for the active set.
*
* @param array<string,string> $detected Slug → version map.
* @param array<string,TMDO_HivePress_Adapter> $adapters Slug → adapter instance.
*/
do_action( 'wpdo_hivepress_booted', self::$detected, self::$adapters );
}
/**
* Force re-boot on next call (for tests + plugin activation).
*
* @internal
*/
public static function reset_for_tests(): void {
self::$booted = false;
self::$adapters = array();
self::$detected = array();
}
/**
* Read-only accessor for active adapters.
*
* @return array<string, TMDO_HivePress_Adapter>
*/
public static function adapters(): array {
return self::$adapters;
}
/**
* Read-only accessor for detection snapshot at boot time.
*
* @return array<string,string>
*/
public static function detected(): array {
return self::$detected;
}
/**
* Look up the adapter for a given addon slug, or null when none active.
*
* @param string $slug Addon slug.
*/
public static function adapter_for( string $slug ): ?object {
return self::$adapters[ $slug ] ?? null;
}
// ── Internal: per-adapter binding ───────────────────────────────────
/**
* Instantiate and wire one adapter's lifecycle hooks.
*
* @param string $slug Detected addon slug.
* @param string $version Detected version string ('' if unknown).
*/
private static function bind_adapter( string $slug, string $version ): void {
$class = self::ADAPTER_MAP[ $slug ] ?? '';
if ( '' === $class || ! class_exists( $class ) ) {
// Adapter for this slug not yet implemented (future Sprint).
return;
}
try {
$adapter = new $class();
} catch ( \Throwable $e ) {
if ( class_exists( 'TMDO_Logger' ) && method_exists( 'TMDO_Logger', 'error' ) ) {
TMDO_Logger::error(
'hivepress-integration',
sprintf( 'Failed to instantiate %s: %s', $class, $e->getMessage() )
);
}
return;
}
if ( ! $adapter instanceof TMDO_HivePress_Adapter ) {
if ( class_exists( 'TMDO_Logger' ) && method_exists( 'TMDO_Logger', 'warn' ) ) {
TMDO_Logger::warn(
'hivepress-integration',
sprintf( '%s does not implement TMDO_HivePress_Adapter — skipping', $class )
);
}
return;
}
// Version floor check.
$min = $adapter->minimum_addon_version();
if ( '' !== $min && '' !== $version && version_compare( $version, $min, '<' ) ) {
if ( class_exists( 'TMDO_Logger' ) && method_exists( 'TMDO_Logger', 'warn' ) ) {
TMDO_Logger::warn(
'hivepress-integration',
sprintf( '%s installed version %s is below minimum %s — adapter idle', $slug, $version, $min )
);
}
return;
}
self::$adapters[ $slug ] = $adapter;
// Adapter constructor is contractually responsible for calling
// `$this->bind_anti_eav_hooks()` (inherited from
// TMDO_Anti_EAV_Aware via TMDO_HivePress_Adapter_Trait) so the
// three `wpdo_register_*` actions get wired. Bootstrap then only
// triggers the addon-specific extras: query/event hooks.
$adapter->on_register_query_hooks();
$adapter->on_register_event_hooks();
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,166 @@
<?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 )
@@ -0,0 +1,266 @@
<?php
/**
* Conflict guard against legacy HivePress data-layer plugins.
*
* `wp-data-optimizer` v3.0.0 fully supersedes `hp-custom-tables` (HPCT) and
* `hp-info-cards`. When either is detected alongside WPDO HivePress
* integration, dual-write would corrupt zone tables (HPCT writes to its own
* `hpct_*` tables while WPDO writes to `wpdo_hot_hp_*`, then both copies
* drift) and admin tooling would show contradictory state.
*
* 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.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
/**
* Static guard checking for legacy plugin conflicts.
*
* Stateful across requests via memo cache only; persists no DB state.
*/
final class TMDO_HivePress_Conflict_Guard {
/**
* Conflicting legacy plugins to detect.
*
* Probe by either `class_exists()` OR `defined()` — both signals are
* checked since plugin authors are inconsistent about which they expose.
*
* @var array<string, array{classes:array<int,string>, consts:array<int,string>, name:string}>
*/
private const CONFLICTS = array(
'hp-custom-tables' => array(
'classes' => array(
'HPCT_Core',
'HPCT_Plugin',
'HP_Custom_Tables',
),
'consts' => array(
'HPCT_VERSION',
'HP_CUSTOM_TABLES_VERSION',
),
'name' => 'HP Custom Tables',
),
'hp-info-cards' => array(
'classes' => array(
'HP_Info_Cards',
'HPIC_Plugin',
),
'consts' => array(
'HP_INFO_CARDS_VERSION',
'HPIC_VERSION',
),
'name' => 'HP Info Cards',
),
);
/**
* Memoized detection result for the current request.
*
* @var array<int,string>|null
*/
private static ?array $memo = null;
/**
* Whether the admin_notices hook has been bound this request.
*
* @var bool
*/
private static bool $notice_bound = false;
/**
* Run the conflict check at boot time.
*
* Called by `TMDO_HivePress_Bootstrap::boot()` BEFORE adapter binding
* so the `wpdo_hivepress_should_bind` filter has chance to short-circuit
* adapter wiring when conflicts exist.
*/
public static function check_and_warn(): void {
$conflicts = self::detect_conflicts();
if ( empty( $conflicts ) ) {
return;
}
self::log_conflicts( $conflicts );
self::ensure_notice_bound();
self::install_should_bind_short_circuit( $conflicts );
}
/**
* Detect currently-active legacy plugins.
*
* @return array<int,string> Slugs of detected conflicting plugins.
*/
public static function detect_conflicts(): array {
if ( null !== self::$memo ) {
return self::$memo;
}
$out = array();
foreach ( self::CONFLICTS as $slug => $probe ) {
if ( self::probe_one( $probe ) ) {
$out[] = $slug;
}
}
self::$memo = $out;
return $out;
}
/**
* Reset internal state for unit tests.
*
* @internal
*/
public static function reset_for_tests(): void {
self::$memo = null;
self::$notice_bound = false;
}
/**
* Whether any conflict is currently active.
*/
public static function has_conflict(): bool {
return ! empty( self::detect_conflicts() );
}
/**
* Render the admin notice (bound only when conflicts exist).
*
* @internal Called by WordPress; do not call directly.
*/
public static function render_notice(): void {
if ( ! function_exists( 'is_admin' ) || ! is_admin() ) {
return;
}
$conflicts = self::detect_conflicts();
if ( empty( $conflicts ) ) {
return;
}
$names = array_map(
static function ( string $slug ): string {
$probe = self::CONFLICTS[ $slug ] ?? null;
if ( null === $probe ) {
return $slug;
}
$name = (string) ( $probe['name'] ?? $slug );
return esc_html( $name );
},
$conflicts
);
$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' ),
implode( ', ', $names )
);
printf(
'<div class="notice notice-warning"><p><strong>%s</strong></p><p>%s</p></div>',
esc_html__( 'WP Data Optimizer — HivePress integration paused', 'tmdo-hivepress' ),
esc_html( $message )
);
}
// ── Internal helpers ────────────────────────────────────────────────
/**
* Probe a single legacy plugin for presence.
*
* @param array{classes:array<int,string>, consts:array<int,string>, name:string} $probe Probe spec.
*/
private static function probe_one( array $probe ): bool {
foreach ( (array) $probe['classes'] as $cls ) {
if ( '' !== $cls && class_exists( $cls ) ) {
return true;
}
}
foreach ( (array) $probe['consts'] as $const ) {
if ( '' !== $const && defined( $const ) ) {
return true;
}
}
return false;
}
/**
* Log conflicts to WPDO error log so audit script picks them up.
*
* @param array<int,string> $conflicts Conflict slugs.
*/
private static function log_conflicts( array $conflicts ): void {
if ( ! class_exists( 'TMDO_Logger' ) ) {
return;
}
$message = sprintf(
'Conflict detected: %s. wp-data-optimizer fully supersedes these. Run: wp wpdo hivepress migrate-from-hpct',
implode( ', ', $conflicts )
);
// Prefer warn() but fall back to log() if the API differs.
if ( method_exists( 'TMDO_Logger', 'warn' ) ) {
TMDO_Logger::warn( 'hivepress-integration', $message );
} elseif ( method_exists( 'TMDO_Logger', 'log' ) ) {
TMDO_Logger::log( 'warning', 'hivepress-integration', $message );
}
}
/**
* Bind the admin notice exactly once per request.
*/
private static function ensure_notice_bound(): void {
if ( self::$notice_bound ) {
return;
}
if ( function_exists( 'add_action' ) ) {
add_action( 'admin_notices', array( __CLASS__, 'render_notice' ) );
}
self::$notice_bound = true;
}
/**
* Install the short-circuit so HivePress adapter binding is skipped.
*
* Bootstrap reads `apply_filters('wpdo_hivepress_should_bind', true, $context)`
* before binding adapters. We force `false` whenever conflicts exist
* so dual-write cannot occur. The conflict context is passed to filter
* so other code can react.
*
* @param array<int,string> $conflicts Conflict slugs.
*/
private static function install_should_bind_short_circuit( array $conflicts ): void {
if ( ! function_exists( 'add_filter' ) ) {
return;
}
add_filter(
'wpdo_hivepress_should_bind',
static function ( $should_bind, $context = array() ) use ( $conflicts ) {
unset( $context );
return false;
// Note: $conflicts captured purely for greppability via closure inspection.
// PHPCS may flag the `unset` if context is unused, but keeping the symmetry
// with the documented filter signature.
},
1,
2
);
unset( $conflicts ); // Satisfy use-after for static analysis.
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,146 @@
<?php
/**
* Cron optimizer — replaces HivePress' hourly listing-expiry full scan.
*
* HivePress core's `class-listing.php::hourly()` walks every `hp_listing`
* post and reads `hp_expired_time` / `hp_featured_time` postmeta, then
* dispatches expire actions. With N listings × 2 meta keys this is 2N
* postmeta lookups per hour.
*
* After Sprint 1 added `hp_expired_time` + `hp_featured_time` to
* `wp_wpdo_hot_hp_listing` as indexed BIGINT columns, we can replace the
* scan with two indexed range queries: `WHERE expired_time BETWEEN 1 AND
* UNIX_TIMESTAMP()`.
*
* Disabled by default. Operator opts in via:
*
* wp option update wpdo_hivepress_cron_optimizer_enabled 1
*
* The optimizer hooks into `hivepress/v1/events/hourly` at priority 5
* (before HP's own callback) and short-circuits the scan when the hot
* table is reachable. If the hot table is missing or the relevant
* `hot_hp_listing` module isn't in cutover/complete state, the
* optimizer yields silently to HP's original code path.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Cron_Optimizer' ) ) {
/**
* Cron optimizer for HivePress hourly listing expiry.
*
* Single-instance, registered via static `register()`.
*/
final class TMDO_HivePress_Cron_Optimizer {
/** Operator opt-in option key. */
public const OPTION_ENABLED = 'wpdo_hivepress_cron_optimizer_enabled';
/** Module name in TMDO_Feature_Flags terminology. */
public const MODULE = 'hot_hp_listing';
/**
* Whether `register()` has bound hooks this request.
*
* @var bool
*/
private static bool $bound = false;
/**
* Bind the hourly hook (idempotent).
*
* Called by Bootstrap during core adapter event-hook registration so
* activation follows the same gate as adapter binding.
*/
public static function register(): void {
if ( self::$bound ) {
return;
}
if ( ! self::is_enabled() ) {
return;
}
add_action( 'hivepress/v1/events/hourly', array( __CLASS__, 'maybe_run' ), 5, 0 );
self::$bound = true;
}
/**
* Reset internal state (test only).
*
* @internal
*/
public static function reset_for_tests(): void {
self::$bound = false;
}
/**
* Whether the optimizer is enabled via operator option.
*/
public static function is_enabled(): bool {
if ( ! function_exists( 'get_option' ) ) {
return false;
}
return (bool) (int) get_option( self::OPTION_ENABLED, 0 );
}
/**
* Hourly hook: dispatch expire actions for listings whose hot-zone
* `expired_time` falls in (0, NOW()] window.
*
* Yields silently when the hot table is missing or the module is not
* in a state where reads are guaranteed accurate.
*
* @return int Number of listings that received the expire action (for tests).
*/
public static function maybe_run(): int {
if ( ! self::module_can_read() ) {
return 0;
}
global $wpdb;
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
return 0;
}
$table = $wpdb->prefix . 'wpdo_hot_hp_listing';
$now = time();
// Indexed range scan replaces O(N) postmeta walk.
$ids = (array) $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is wpdb prefix + literal.
$wpdb->prepare(
"SELECT post_id FROM `{$table}` WHERE expired_time > 0 AND expired_time <= %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is wpdb prefix + literal.
$now
)
);
$count = 0;
foreach ( $ids as $post_id ) {
$post_id = (int) $post_id;
if ( $post_id <= 0 ) {
continue;
}
do_action( 'hivepress/v1/models/listing/expire', $post_id );
++$count;
}
return $count;
}
/**
* Whether the `hot_hp_listing` module is in a state where the hot
* table reflects current truth (cutover or complete).
*/
private static function module_can_read(): bool {
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
return false;
}
return TMDO_Feature_Flags::is_query_active( self::MODULE );
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,260 @@
<?php
/**
* HivePress family addon detector.
*
* Probes for the 13 known HivePress family plugins by checking the most
* stable `class_exists()` / `defined()` signal each addon publishes. Returns
* a `slug => version` map of detected addons.
*
* Detection happens during `TMDO_HivePress_Bootstrap::boot()` on
* `plugins_loaded:5`, AFTER WPDO core (priority 4) but BEFORE HivePress own
* boot (priority 8). The bootstrap then instantiates an adapter per detected
* addon and binds its lifecycle hooks.
*
* Result is cached in a 5-min transient (`wpdo_hivepress_detector_cache`) so
* repeated calls within a single page-load (e.g. admin tab + REST endpoint)
* don't re-probe. Cache busts on plugin activation/deactivation via the
* static `bust_cache()` method.
*
* Cost: when HivePress core is NOT installed, detector short-circuits after
* a single `class_exists('HivePress\\Core')` check — zero-cost fallback path
* for sites that don't use HivePress.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Detector' ) ) {
/**
* Static detector for HivePress family addons.
*
* Stateless apart from the request-level memo cache; reset between tests
* via `reset_for_tests()`.
*/
final class TMDO_HivePress_Detector {
/** Transient key for the detection cache. */
private const CACHE_KEY = 'wpdo_hivepress_detector_cache';
/** Transient TTL (5 minutes — short enough to pick up plugin activation, long enough to avoid per-request cost). */
private const CACHE_TTL = 300;
/**
* Request-level memo cache. Survives only the current request.
*
* @var array<string,string>|null
*/
private static ?array $memo = null;
/**
* Detection probe table: slug => [class, version_const, name].
*
* Keep this list in sync with the HivePress family on wordpress.org.
* Detection prefers `class_exists()` over `defined()` because version
* constants sometimes leak to other globals; the class probe is more
* specific. Both signals are checked — either one suffices.
*
* For addons not yet installed locally (bookings, marketplace,
* statistics) the probe still runs — it just returns no match. The
* adapter file exists for future installs.
*
* @var array<string, array{class:string, const:string, name:string}>
*/
private const PROBES = array(
'hivepress' => array(
'class' => 'HivePress\\Core',
'const' => 'HIVEPRESS_VERSION',
'name' => 'HivePress',
),
'hivepress-blocks' => array(
'class' => 'HivePress\\Blocks\\Plugin',
'const' => 'HIVEPRESS_BLOCKS_VERSION',
'name' => 'HivePress Blocks',
),
'hivepress-bookings' => array(
'class' => 'HivePress\\Bookings\\Plugin',
'const' => 'HIVEPRESS_BOOKINGS_VERSION',
'name' => 'HivePress Bookings',
),
'hivepress-favorites' => array(
'class' => 'HivePress\\Favorites\\Plugin',
'const' => 'HIVEPRESS_FAVORITES_VERSION',
'name' => 'HivePress Favorites',
),
'hivepress-marketplace' => array(
'class' => 'HivePress\\Marketplace\\Plugin',
'const' => 'HIVEPRESS_MARKETPLACE_VERSION',
'name' => 'HivePress Marketplace',
),
'hivepress-memberships' => array(
'class' => 'HivePress\\Memberships\\Plugin',
'const' => 'HIVEPRESS_MEMBERSHIPS_VERSION',
'name' => 'HivePress Memberships',
),
'hivepress-messages' => array(
'class' => 'HivePress\\Messages\\Plugin',
'const' => 'HIVEPRESS_MESSAGES_VERSION',
'name' => 'HivePress Messages',
),
'hivepress-requests' => array(
'class' => 'HivePress\\Requests\\Plugin',
'const' => 'HIVEPRESS_REQUESTS_VERSION',
'name' => 'HivePress Requests',
),
'hivepress-reviews' => array(
'class' => 'HivePress\\Reviews\\Plugin',
'const' => 'HIVEPRESS_REVIEWS_VERSION',
'name' => 'HivePress Reviews',
),
'hivepress-seo' => array(
'class' => 'HivePress\\Seo\\Plugin',
'const' => 'HIVEPRESS_SEO_VERSION',
'name' => 'HivePress SEO',
),
'hivepress-social-links' => array(
'class' => 'HivePress\\SocialLinks\\Plugin',
'const' => 'HIVEPRESS_SOCIAL_LINKS_VERSION',
'name' => 'HivePress Social Links',
),
'hivepress-statistics' => array(
'class' => 'HivePress\\Statistics\\Plugin',
'const' => 'HIVEPRESS_STATISTICS_VERSION',
'name' => 'HivePress Statistics',
),
'hivepress-tags' => array(
'class' => 'HivePress\\Tags\\Plugin',
'const' => 'HIVEPRESS_TAGS_VERSION',
'name' => 'HivePress Tags',
),
);
/**
* Detect installed HivePress family addons.
*
* Returns map of slug → version-string. Empty array when HivePress
* core is not installed (zero-overhead short circuit for non-HP sites).
*
* @return array<string,string>
*/
public static function detect(): array {
if ( null !== self::$memo ) {
return self::$memo;
}
$cached = function_exists( 'get_transient' ) ? get_transient( self::CACHE_KEY ) : false;
if ( is_array( $cached ) ) {
self::$memo = $cached;
return $cached;
}
// Short-circuit: if HivePress core is not present, no addon can be loaded.
if ( ! self::probe_one( self::PROBES['hivepress'] ) ) {
self::$memo = array();
if ( function_exists( 'set_transient' ) ) {
set_transient( self::CACHE_KEY, self::$memo, self::CACHE_TTL );
}
return self::$memo;
}
$found = array();
foreach ( self::PROBES as $slug => $probe ) {
if ( self::probe_one( $probe ) ) {
$found[ $slug ] = self::version_for( $probe );
}
}
self::$memo = $found;
if ( function_exists( 'set_transient' ) ) {
set_transient( self::CACHE_KEY, $found, self::CACHE_TTL );
}
return $found;
}
/**
* Force re-detection on next call.
*
* Call from plugin activation / deactivation hooks if the listening
* code wants up-to-date detection state immediately.
*/
public static function bust_cache(): void {
self::$memo = null;
if ( function_exists( 'delete_transient' ) ) {
delete_transient( self::CACHE_KEY );
}
}
/**
* Reset internal state for unit tests.
*
* @internal
*/
public static function reset_for_tests(): void {
self::$memo = null;
}
/**
* Catalog of supported addons for UI display (slug => human-readable name).
*
* @return array<string,string>
*/
public static function catalog(): array {
$out = array();
foreach ( self::PROBES as $slug => $probe ) {
$out[ $slug ] = $probe['name'];
}
return $out;
}
/**
* Whether a given addon slug is in the supported catalog.
*
* @param string $slug Addon slug.
*/
public static function is_known( string $slug ): bool {
return isset( self::PROBES[ $slug ] );
}
// ── Internal probes ─────────────────────────────────────────────────
/**
* Probe a single addon. Either the class OR the const must exist.
*
* @param array{class:string, const:string, name:string} $probe Probe definition.
*/
private static function probe_one( array $probe ): bool {
$class = (string) ( $probe['class'] ?? '' );
$const = (string) ( $probe['const'] ?? '' );
if ( '' !== $class && class_exists( $class ) ) {
return true;
}
if ( '' !== $const && defined( $const ) ) {
return true;
}
return false;
}
/**
* Best-effort version extraction. Empty string when no version available.
*
* @param array{class:string, const:string, name:string} $probe Probe definition.
*/
private static function version_for( array $probe ): string {
$const = (string) ( $probe['const'] ?? '' );
if ( '' !== $const && defined( $const ) ) {
$value = constant( $const );
if ( is_string( $value ) || is_numeric( $value ) ) {
return (string) $value;
}
}
return '';
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,168 @@
<?php
/**
* REST API endpoints for the HivePress family integration.
*
* Three read-only endpoints (manage_options capability required):
*
* GET /wpdo/v1/hivepress/status → detection + adapter binding state
* GET /wpdo/v1/hivepress/score → 8-D suitability report
* GET /wpdo/v1/hivepress/health → doctor probes for each adapter
*
* No mutating endpoints in v3.0.0 — `migrate` / `rollback` are CLI-only
* because they alter site-wide FSM state (Karpathy: high-blast-radius
* actions need explicit operator intent, not a button click).
*
* Bound on `rest_api_init` from wp-data-optimizer.php after the existing
* `TMDO_REST_API` registration.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_REST' ) ) {
/**
* REST controller for the HivePress integration.
*/
final class TMDO_HivePress_REST {
/** REST namespace. */
public const NAMESPACE = 'wpdo/v1';
/**
* Register the three GET routes.
*/
public function register_routes(): void {
register_rest_route(
self::NAMESPACE,
'/hivepress/status',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_status' ),
'permission_callback' => array( $this, 'check_permission' ),
)
);
register_rest_route(
self::NAMESPACE,
'/hivepress/score',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_score' ),
'permission_callback' => array( $this, 'check_permission' ),
)
);
register_rest_route(
self::NAMESPACE,
'/hivepress/health',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_health' ),
'permission_callback' => array( $this, 'check_permission' ),
)
);
}
/**
* Capability check — operator-only (manage_options).
*
* @return bool|\WP_Error
*/
public function check_permission() {
if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'manage_options' ) ) {
return new WP_Error(
'wpdo_hp_forbidden',
__( 'You do not have permission to read HivePress integration data.', 'tmdo-hivepress' ),
array( 'status' => 403 )
);
}
return true;
}
/**
* GET /hivepress/status — detection + binding state.
*/
public function get_status() {
$detected = class_exists( 'TMDO_HivePress_Detector' )
? TMDO_HivePress_Detector::detect()
: array();
$catalog = class_exists( 'TMDO_HivePress_Detector' )
? TMDO_HivePress_Detector::catalog()
: array();
$bound = class_exists( 'TMDO_HivePress_Bootstrap' )
? array_keys( TMDO_HivePress_Bootstrap::adapters() )
: array();
$conflict = class_exists( 'TMDO_HivePress_Conflict_Guard' )
? TMDO_HivePress_Conflict_Guard::detect_conflicts()
: array();
$addons = array();
foreach ( $catalog as $slug => $name ) {
$addons[] = array(
'slug' => $slug,
'name' => $name,
'detected' => isset( $detected[ $slug ] ),
'version' => $detected[ $slug ] ?? null,
'bound' => in_array( $slug, $bound, true ),
);
}
return new WP_REST_Response(
array(
'addons' => $addons,
'conflicts' => $conflict,
'totals' => array(
'detected' => count( $detected ),
'bound' => count( $bound ),
'catalog' => count( $catalog ),
),
),
200
);
}
/**
* GET /hivepress/score — 8-D suitability report.
*/
public function get_score() {
if ( ! class_exists( 'TMDO_HivePress_Suitability_Scorer' ) ) {
return new WP_REST_Response( array( 'error' => 'scorer_unavailable' ), 503 );
}
return new WP_REST_Response( TMDO_HivePress_Suitability_Scorer::report(), 200 );
}
/**
* GET /hivepress/health — doctor probes for each adapter.
*/
public function get_health() {
$adapters = class_exists( 'TMDO_HivePress_Bootstrap' )
? TMDO_HivePress_Bootstrap::adapters()
: array();
$probes = array();
$ok = true;
foreach ( $adapters as $slug => $adapter ) {
if ( ! $adapter instanceof TMDO_HivePress_Adapter ) {
continue;
}
$probe = $adapter->doctor_check();
$probes[ $slug ] = $probe;
if ( empty( $probe['ok'] ) ) {
$ok = false;
}
}
return new WP_REST_Response(
array(
'ok' => $ok,
'probes' => $probes,
),
200
);
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,141 @@
<?php
/**
* 8-dimension anti-EAV suitability scorer (aggregate across all adapters).
*
* Each adapter self-reports an 8-D score via `suitability_score()`. This
* scorer collects all reports from `TMDO_HivePress_Bootstrap::adapters()`
* and computes:
*
* - Per-adapter aggregate (already in adapter's own report)
* - Per-dimension average across all adapters
* - Site-wide aggregate (mean of all adapter aggregates)
*
* The output feeds:
* - `wp wpdo hivepress score` CLI (Sprint 4)
* - Admin UI HivePress tab (Sprint 4)
* - REST endpoint /wpdo/v1/hivepress/score (Sprint 4)
*
* Per Karpathy guideline: a sum/avg, not a fancy weighted model. If the
* score needs to be more sophisticated later, tradeoff it then.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Suitability_Scorer' ) ) {
/**
* Aggregate scorer for active HivePress adapters.
*
* Stateless — every call rebuilds the report from current adapter state.
*/
final class TMDO_HivePress_Suitability_Scorer {
/** Dimension keys (must match adapter `suitability_score()` output). */
public const DIMENSIONS = array(
'd1_meta_calls',
'd2_meta_sql',
'd3_table_coverage',
'd4_registry_meta',
'd5_hook_bus',
'd6_options',
'd7_coupling',
'd8_perf',
);
/**
* Build a complete score report for all currently active adapters.
*
* @return array{adapters:array<string,array<string,float>>, per_dimension:array<string,float>, aggregate:float, adapter_count:int}
*/
public static function report(): array {
$adapters = class_exists( 'TMDO_HivePress_Bootstrap' )
? TMDO_HivePress_Bootstrap::adapters()
: array();
$per_adapter = array();
foreach ( $adapters as $slug => $adapter ) {
if ( $adapter instanceof TMDO_HivePress_Adapter ) {
$per_adapter[ $slug ] = $adapter->suitability_score();
}
}
return array(
'adapters' => $per_adapter,
'per_dimension' => self::compute_per_dimension( $per_adapter ),
'aggregate' => self::compute_aggregate( $per_adapter ),
'adapter_count' => count( $per_adapter ),
);
}
/**
* Compute the per-dimension average across all adapter reports.
*
* @param array<string,array<string,float>> $per_adapter Adapter score map.
* @return array<string,float> Dimension key → average value.
*/
public static function compute_per_dimension( array $per_adapter ): array {
$out = array();
if ( empty( $per_adapter ) ) {
foreach ( self::DIMENSIONS as $d ) {
$out[ $d ] = 0.0;
}
return $out;
}
foreach ( self::DIMENSIONS as $d ) {
$sum = 0.0;
foreach ( $per_adapter as $score ) {
$sum += (float) ( $score[ $d ] ?? 0.0 );
}
$out[ $d ] = round( $sum / count( $per_adapter ), 2 );
}
return $out;
}
/**
* Compute the site-wide aggregate (mean of adapter aggregates).
*
* @param array<string,array<string,float>> $per_adapter Adapter score map.
*/
public static function compute_aggregate( array $per_adapter ): float {
if ( empty( $per_adapter ) ) {
return 0.0;
}
$sum = 0.0;
foreach ( $per_adapter as $score ) {
$sum += (float) ( $score['aggregate'] ?? 0.0 );
}
return round( $sum / count( $per_adapter ), 2 );
}
/**
* Identify which dimensions are dragging the aggregate down.
*
* Returns adapter slugs whose given dimension is below the threshold.
*
* @param string $dimension Dimension key (e.g. 'd8_perf').
* @param float $threshold Minimum acceptable value (default 1.0).
* @return array<int,string> Slugs needing attention for this dimension.
*/
public static function adapters_below_threshold( string $dimension, float $threshold = 1.0 ): array {
if ( ! in_array( $dimension, self::DIMENSIONS, true ) ) {
return array();
}
$report = self::report();
$flagged = array();
foreach ( $report['adapters'] as $slug => $score ) {
if ( ( (float) ( $score[ $dimension ] ?? 0.0 ) ) < $threshold ) {
$flagged[] = $slug;
}
}
return $flagged;
}
}
} // end if ( ! class_exists )
@@ -0,0 +1,172 @@
<?php
/**
* Interface contract for a HivePress addon adapter.
*
* Each adapter represents one HivePress family plugin (`hivepress`,
* `hivepress-bookings`, `hivepress-reviews`, etc.) and is responsible for:
*
* - Declaring how to detect the addon (class / version constant)
* - Registering Hot/Warm/Cold/Archive zone field mappings owned by the addon
* - Registering entity field groups (post/user/term/comment)
* - Registering custom tables (if the addon owns its own tables)
* - Wiring query/comment routers (meta_query → JOIN rewrites)
* - Subscribing to `wpdo_after_write` for cross-addon event handling
* - Reporting an 8-dimension anti-EAV suitability score
* - Reporting health doctor probes
* - Listing the FSM migrations the adapter participates in
*
* The interface is intentionally `*_*` no-arg methods (instead of `register()`
* static entrypoint) because adapters are managed by `TMDO_HivePress_Bootstrap`
* — it instantiates one adapter per detected addon and binds the lifecycle
* methods to WPDO core hooks. Static `register()` is reserved for the
* Bootstrap itself, mirroring the existing partner-integration pattern.
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! interface_exists( 'TMDO_HivePress_Adapter' ) ) {
/**
* Public surface every HivePress addon adapter MUST satisfy.
*
* The Trait `TMDO_HivePress_Adapter_Trait` provides safe no-op defaults for
* everything except `plugin_slug()` / `detection_class()` / `detection_const()`,
* so concrete adapters typically override 36 methods.
*/
interface TMDO_HivePress_Adapter {
/**
* Stable slug used as Schema_Registry / Custom_Table_Registry provider key.
*
* MUST exactly match the wp.org plugin slug (e.g. 'hivepress-reviews')
* so cross-tooling (CLI, admin UI, audit script) can correlate.
*/
public function plugin_slug(): string;
/**
* Fully-qualified class name whose presence indicates the addon is loaded.
*
* Adapter Bootstrap calls `class_exists()` on this string. If the addon
* publishes multiple Plugin/Core classes, return the most stable one
* (the one least likely to be renamed across releases).
*/
public function detection_class(): string;
/**
* Version constant whose `defined()` indicates the addon is loaded.
*
* Used as a backup signal when `class_exists()` is too late (e.g.
* adapters need the version string for compatibility gating). Return
* empty string when the addon does not expose a version constant.
*/
public function detection_const(): string;
/**
* Minimum HivePress addon version this adapter supports.
*
* Bootstrap compares against `defined($detection_const)` value and
* downgrades to `idle` mode (no hooks bound) when the installed
* version is below this floor. Return empty string to skip gating.
*/
public function minimum_addon_version(): string;
/**
* Register Hot/Warm/Cold/Archive zone field mappings.
*
* Called by Bootstrap on the `wpdo_register_fields` action, which fires
* inside `TMDO_Core::run()` at `plugins_loaded:4`. Schema_Registry
* already has internal dedup so re-firing on `init:1` is safe.
*
* @param TMDO_Schema_Registry $registry Singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void;
/**
* Register entity field groups (post/user/term/comment).
*
* Called on `wpdo_register_entity_fields`. Adapters that contribute
* user/term/comment fields (e.g. core adapter for hp_user) hook here.
*/
public function on_register_entity_fields(): void;
/**
* Register custom tables owned by the adapter (e.g. comment hot tables).
*
* Called on `wpdo_register_custom_tables`. Each table SHOULD include
* `expected_columns` + `expected_indexes` so `wp wpdo doctor` can
* validate. Tables are NOT created here — adapters declare ownership
* for tooling; actual DDL is owned by `TMDO_Schema_Manager`.
*
* @param TMDO_Custom_Table_Registry $registry Singleton.
*/
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void;
/**
* Register WP_Query / WP_Comment_Query rewriting hooks.
*
* 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`.
*
* Called on `init:5` after Schema_Registry is fully populated.
*/
public function on_register_query_hooks(): void;
/**
* Subscribe to `wpdo_after_write` for cross-adapter event handling.
*
* Adapters that participate in cross-cutting workflows (e.g. listing
* sold-out → block bookings) hook here. Called once during boot.
*/
public function on_register_event_hooks(): void;
/**
* Self-report 8-dimension anti-EAV suitability score.
*
* Returns a flat array with keys `d1_meta_calls .. d8_perf` (each
* 0.01.0) plus an `aggregate` key (0.010.0). The
* `TMDO_HivePress_Suitability_Scorer` may inspect adapter source to
* verify, but the adapter's self-report is the primary signal.
*
* Concrete implementation guidance:
* D1: 1.0 if adapter never calls *_meta() directly, only TMDO_API
* D2: 1.0 if no hardcoded wp_postmeta etc. literals
* D3: 1.0 if every hot meta_key has a registered hot column
* D4: 1.0 if every registered table includes expected_columns + indexes
* D5: 1.0 if adapter subscribes to wpdo_after_write (when applicable)
* D6: 1.0 if zero autoload=yes options + zero raw transients
* D7: 1.0 if no direct queries against another adapter's tables
* D8: 1.0 if EXPLAIN of representative queries shows index scan only
*
* @return array{d1_meta_calls:float, d2_meta_sql:float, d3_table_coverage:float, d4_registry_meta:float, d5_hook_bus:float, d6_options:float, d7_coupling:float, d8_perf:float, aggregate:float}
*/
public function suitability_score(): array;
/**
* Health doctor probe — table existence, row counts, index sanity.
*
* Called by `wp wpdo hivepress doctor` and admin UI HivePress tab.
* MUST never throw; catch and report as `ok=false` with message.
*
* @return array{ok:bool, message:string, details?:array<string,mixed>}
*/
public function doctor_check(): array;
/**
* Module names this adapter contributes to the 7-state FSM.
*
* Each module is independently transitionable via `wp wpdo migrate
* <module>` etc. Names MUST be unique across adapters and align with
* `TMDO_Feature_Flags::ZONE_MODULES` registry conventions.
*
* @return array<int, string> e.g. ['hot_hp_listing', 'hot_hp_request']
*/
public function migrations(): array;
}
} // end if ( ! interface_exists )
@@ -0,0 +1,272 @@
<?php
/**
* Shared implementation trait for HivePress addon adapters.
*
* Composes `TMDO_Anti_EAV_Aware` (the canonical sugar layer for partner
* plugins) and adds HivePress-specific helpers:
*
* - Hot/Warm/Cold zone field registration shortcuts (default `hp_*` post types)
* - Custom-table registration with auto-filled HP conventions
* - 8-dimension suitability self-score skeleton with sensible defaults
* - Default no-op implementations for query/event hooks
* - `on_after_addon_write()` cross-adapter event subscription helper
*
* Concrete adapters `use TMDO_HivePress_Adapter_Trait;` and override only
* what they need. This keeps each adapter file focused on its domain
* (Listing fields, Booking fields, Review aggregations, etc.).
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! trait_exists( 'TMDO_HivePress_Adapter_Trait' ) ) {
trait TMDO_HivePress_Adapter_Trait {
// Compose the canonical sugar trait so adapters get its helpers
// (`register_field`, `register_custom_table`, `register_entity_fields`,
// `get_field`, `set_field`, `on_after_write`, `bind_anti_eav_hooks`)
// without each having to `use` two traits.
use TMDO_Anti_EAV_Aware;
// ── Default no-op implementations for the interface ────────────────
/**
* Override in concrete adapter (default 'hivepress' for safety).
*/
public function plugin_slug(): string {
return 'hivepress';
}
/**
* Override in concrete adapter (default empty string disables detection).
*/
public function detection_class(): string {
return '';
}
/**
* Override in concrete adapter (default empty string).
*/
public function detection_const(): string {
return '';
}
/**
* Override in concrete adapter to set version floor.
*
* Default empty string = accept any installed version.
*/
public function minimum_addon_version(): string {
return '';
}
/**
* Default no-op for adapters with no zone fields (e.g. presentation-only addons).
*
* @param TMDO_Schema_Registry $registry Schema registry singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
unset( $registry );
}
/**
* Default no-op — most addons don't contribute entity fields.
*
* `TMDO_Anti_EAV_Aware::on_register_entity_fields()` is overridden in
* concrete adapters that own user/term/comment groups (e.g. core
* adapter for hp_user). The trait's own default is also no-op so this
* override exists only to satisfy the interface contract symmetry.
*/
public function on_register_entity_fields(): void {
// Intentional no-op.
}
/**
* Default no-op for adapters with no custom tables.
*
* @param TMDO_Custom_Table_Registry $registry Custom table registry singleton.
*/
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void {
unset( $registry );
}
/**
* Default no-op — adapter has no query routing needs.
*
* Adapters with hot zone fields used in search/filter override and
* call `TMDO_HivePress_Query_Router::register_for( $this )` here.
*/
public function on_register_query_hooks(): void {
// Intentional no-op.
}
/**
* Default no-op — adapter has no cross-adapter event handling.
*/
public function on_register_event_hooks(): void {
// Intentional no-op.
}
/**
* Self-report 8-dimension anti-EAV suitability score.
*
* Default returns 1.0 across the board, plus aggregate 10.0. Adapters
* with known weaknesses (e.g. addon-only detection, missing hot
* tables) MUST override and lower the relevant dimensions to keep
* the aggregate honest.
*
* @return array{d1_meta_calls:float, d2_meta_sql:float, d3_table_coverage:float, d4_registry_meta:float, d5_hook_bus:float, d6_options:float, d7_coupling:float, d8_perf:float, aggregate:float}
*/
public function suitability_score(): array {
return $this->compose_score( array() );
}
/**
* Default doctor probe — returns ok with adapter name.
*
* Adapters with custom tables MUST override and verify table existence.
*
* @return array{ok:bool, message:string, details?:array<string,mixed>}
*/
public function doctor_check(): array {
return array(
'ok' => true,
'message' => sprintf( '%s: adapter loaded, no custom tables to probe', $this->plugin_slug() ),
);
}
/**
* Default no FSM migrations (adapter is read-only or zero hot fields).
*
* @return array<int, string>
*/
public function migrations(): array {
return array();
}
// ── Helper: zone-aware field registration shortcuts ─────────────────
/**
* Register a single hot zone field (varchar/int/decimal column on `wp_wpdo_hot_<post_type>`).
*
* @param TMDO_Schema_Registry $registry Provided by `wpdo_register_fields` hook.
* @param string $post_type WordPress post type (e.g. 'hp_listing').
* @param string $meta_key wp_postmeta meta_key.
* @param string $data_type SQL column type (e.g. "tinyint(1) NOT NULL DEFAULT 0").
* @param array<string,mixed> $extra Optional overrides: column, indexed, etc.
*/
protected function register_hot( TMDO_Schema_Registry $registry, string $post_type, string $meta_key, string $data_type, array $extra = array() ): void {
$config = array_merge(
array(
'post_type' => $post_type,
'meta_key' => $meta_key,
'zone' => 'hot',
'data_type' => $data_type,
'column' => sanitize_key( $meta_key ),
'indexed' => false,
),
$extra
);
$this->register_field( $registry, $config );
}
/**
* Register a single cold zone field (object cache + JSON blob fallback).
*
* @param TMDO_Schema_Registry $registry Provided by `wpdo_register_fields` hook.
* @param string $post_type WordPress post type.
* @param string $meta_key wp_postmeta meta_key.
* @param int $cache_ttl Cache TTL in seconds (default 1 hour).
* @param array<string,mixed> $extra Optional overrides.
*/
protected function register_cold( TMDO_Schema_Registry $registry, string $post_type, string $meta_key, int $cache_ttl = 3600, array $extra = array() ): void {
$config = array_merge(
array(
'post_type' => $post_type,
'meta_key' => $meta_key,
'zone' => 'cold',
'cache_group' => 'wpdo_cold_' . sanitize_key( $post_type ),
'cache_ttl' => $cache_ttl,
),
$extra
);
$this->register_field( $registry, $config );
}
/**
* Register a single warm zone field (TTL counter / temp value).
*
* @param TMDO_Schema_Registry $registry Provided by `wpdo_register_fields` hook.
* @param string $post_type WordPress post type.
* @param string $meta_key wp_postmeta meta_key.
* @param int|null $ttl TTL in seconds (null = no expiry).
* @param array<string,mixed> $extra Optional overrides.
*/
protected function register_warm( TMDO_Schema_Registry $registry, string $post_type, string $meta_key, ?int $ttl = null, array $extra = array() ): void {
$config = array_merge(
array(
'post_type' => $post_type,
'meta_key' => $meta_key,
'zone' => 'warm',
'ttl' => $ttl,
),
$extra
);
$this->register_field( $registry, $config );
}
/**
* Subscribe to writes on another adapter's keys (cross-adapter coordination).
*
* Wraps `TMDO_Anti_EAV_Aware::on_after_write()` with a description hook
* so the cross-adapter linkage is greppable for the suitability scorer's
* D5 (Hook Bus Integration) check.
*
* @param string $key_prefix Meta key prefix (e.g. 'hp_sold_out_').
* @param callable $callback Receives (entity_type, entity_id, key, value, ok, op, before).
*/
protected function on_after_addon_write( string $key_prefix, callable $callback ): void {
$this->on_after_write( $key_prefix, $callback );
}
// ── Helper: 8-dimension score composition ───────────────────────────
/**
* Compose an 8-dimension score, filling missing keys with 1.0 and
* computing the aggregate as `sum / 8 * 1.25` (so 1.0 across all
* → 10.0).
*
* @param array<string,float> $partial Override values keyed by `d1_meta_calls`...`d8_perf`.
* @return array{d1_meta_calls:float, d2_meta_sql:float, d3_table_coverage:float, d4_registry_meta:float, d5_hook_bus:float, d6_options:float, d7_coupling:float, d8_perf:float, aggregate:float}
*/
protected function compose_score( array $partial ): array {
$defaults = array(
'd1_meta_calls' => 1.0,
'd2_meta_sql' => 1.0,
'd3_table_coverage' => 1.0,
'd4_registry_meta' => 1.0,
'd5_hook_bus' => 1.0,
'd6_options' => 1.0,
'd7_coupling' => 1.0,
'd8_perf' => 1.0,
);
$score = array_merge( $defaults, $partial );
// Clamp every dimension to [0.0, 1.0].
foreach ( $defaults as $k => $_ ) {
$score[ $k ] = max( 0.0, min( 1.0, (float) ( $score[ $k ] ?? 1.0 ) ) );
}
$sum = array_sum( array_intersect_key( $score, $defaults ) );
$score['aggregate'] = round( $sum / 8.0 * 10.0, 2 );
return $score;
}
}
} // end if ( ! trait_exists )
@@ -0,0 +1,117 @@
<?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
}
}
@@ -0,0 +1,339 @@
<?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' )
);
}
}
@@ -0,0 +1,133 @@
<?php
/**
* Memberships interceptor for HivePress Membership posts.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Memberships interceptor — intercepts HivePress Membership reads/writes.
* Operates on hpct_memberships table.
*/
class TMDO_Memberships_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'memberships';
private 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',
);
/**
* Registers WordPress hooks for this interceptor.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'wp_insert_post', array( $this, 'action_insert_post' ), 10, 3 );
add_filter( 'update_post_metadata', array( $this, 'filter_update_meta' ), 10, 5 );
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
}
/**
* Deletes membership record 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_membership' !== $post->post_type || ! $this->is_active() ) {
return;
}
try {
global $wpdb;
$wpdb->delete( TMDO_DB::table( 'hpct_memberships' ), array( 'post_id' => $post_id ), array( '%d' ) );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
}
}
/**
* Filters update_post_metadata for hp_membership posts.
*
* @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_update_meta( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) {
if ( 'hp_membership' !== get_post_type( $post_id ) || ! isset( self::FIELD_MAP[ $meta_key ] ) ) {
return $check;
}
if ( ! $this->is_active() ) {
return $check;
}
try {
global $wpdb;
$col = self::FIELD_MAP[ $meta_key ];
$wpdb->query(
$wpdb->prepare(
'UPDATE ' . TMDO_DB::table( 'hpct_memberships' ) . " SET `{$col}` = %s, updated_at = %s WHERE post_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name from TMDO_DB::table(); column validated by FIELD_MAP constant.
$meta_value,
TMDO_DB::now(),
$post_id
)
);
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() );
}
return $check;
}
/**
* Inserts a new membership record when a post is inserted.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param bool $update Whether this is an update.
* @return void
*/
public function action_insert_post( int $post_id, \WP_Post $post, bool $update ): void {
if ( $update || 'hp_membership' !== $post->post_type || ! $this->is_active() ) {
return;
}
try {
global $wpdb;
$now = TMDO_DB::now();
$wpdb->insert(
TMDO_DB::table( 'hpct_memberships' ),
array(
'post_id' => $post_id,
'user_id' => (int) $post->post_author,
'status' => $post->post_status,
'created_at' => $now,
'updated_at' => $now,
),
array( '%d', '%d', '%s', '%s', '%s' )
);
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'wp_insert_post', $e->getMessage() );
}
}
}
@@ -0,0 +1,135 @@
<?php
/**
* Messages interceptor for HivePress Message posts.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Messages interceptor — intercepts HivePress Message reads/writes.
* Operates on hpct_messages table.
*/
class TMDO_Messages_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'messages';
private const FIELD_MAP = array(
'hp_sender' => 'sender_id',
'hp_recipient' => 'recipient_id',
'hp_listing' => 'listing_id',
'hp_read' => 'is_read',
);
/**
* Registers WordPress hooks for this interceptor.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'wp_insert_post', array( $this, 'action_insert_post' ), 10, 3 );
add_filter( 'update_post_metadata', array( $this, 'filter_update_meta' ), 10, 5 );
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
}
/**
* Deletes message record 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_message' !== $post->post_type || ! $this->is_active() ) {
return;
}
try {
global $wpdb;
$wpdb->delete( TMDO_DB::table( 'hpct_messages' ), array( 'post_id' => $post_id ), array( '%d' ) );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
}
}
/**
* Filters update_post_metadata for hp_message posts.
*
* @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_update_meta( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) {
if ( 'hp_message' !== get_post_type( $post_id ) || ! isset( self::FIELD_MAP[ $meta_key ] ) ) {
return $check;
}
if ( ! $this->is_active() ) {
return $check;
}
try {
global $wpdb;
$col = self::FIELD_MAP[ $meta_key ];
$wpdb->query(
$wpdb->prepare(
'UPDATE ' . TMDO_DB::table( 'hpct_messages' ) . " SET `{$col}` = %s, updated_at = %s WHERE post_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name from TMDO_DB::table(); column validated by FIELD_MAP constant.
$meta_value,
TMDO_DB::now(),
$post_id
)
);
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() );
}
return $check;
}
/**
* Inserts a new message record when a post is inserted.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param bool $update Whether this is an update.
* @return void
*/
public function action_insert_post( int $post_id, \WP_Post $post, bool $update ): void {
if ( $update || 'hp_message' !== $post->post_type || ! $this->is_active() ) {
return;
}
try {
global $wpdb;
$now = TMDO_DB::now();
$wpdb->insert(
TMDO_DB::table( 'hpct_messages' ),
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,
),
array( '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
);
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'wp_insert_post', $e->getMessage() );
}
}
}
@@ -0,0 +1,131 @@
<?php
/**
* Requests interceptor for HivePress Request posts.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Requests interceptor — intercepts HivePress Request reads/writes.
* Operates on hpct_requests table.
*/
class TMDO_Requests_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'requests';
private const FIELD_MAP = array(
'hp_vendor' => 'vendor_id',
'hp_listing' => 'listing_id',
'hp_budget' => 'budget',
);
/**
* Registers WordPress hooks for this interceptor.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'wp_insert_post', array( $this, 'action_insert_post' ), 10, 3 );
add_filter( 'update_post_metadata', array( $this, 'filter_update_meta' ), 10, 5 );
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
}
/**
* Deletes request record 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_request' !== $post->post_type || ! $this->is_active() ) {
return;
}
try {
global $wpdb;
$wpdb->delete( TMDO_DB::table( 'hpct_requests' ), array( 'post_id' => $post_id ), array( '%d' ) );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
}
}
/**
* Filters update_post_metadata for hp_request posts.
*
* @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_update_meta( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) {
if ( 'hp_request' !== get_post_type( $post_id ) || ! isset( self::FIELD_MAP[ $meta_key ] ) ) {
return $check;
}
if ( ! $this->is_active() ) {
return $check;
}
try {
global $wpdb;
$col = self::FIELD_MAP[ $meta_key ];
$wpdb->query(
$wpdb->prepare(
'UPDATE ' . TMDO_DB::table( 'hpct_requests' ) . " SET `{$col}` = %s, updated_at = %s WHERE post_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name from TMDO_DB::table(); column validated by FIELD_MAP constant.
$meta_value,
TMDO_DB::now(),
$post_id
)
);
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() );
}
return $check;
}
/**
* Inserts a new request record when a post is inserted.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param bool $update Whether this is an update.
* @return void
*/
public function action_insert_post( int $post_id, \WP_Post $post, bool $update ): void {
if ( $update || 'hp_request' !== $post->post_type || ! $this->is_active() ) {
return;
}
try {
global $wpdb;
$now = TMDO_DB::now();
$wpdb->insert(
TMDO_DB::table( 'hpct_requests' ),
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,
),
array( '%d', '%d', '%s', '%s', '%s', '%s' )
);
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'wp_insert_post', $e->getMessage() );
}
}
}
@@ -0,0 +1,174 @@
<?php
/**
* Reviews interceptor for HivePress Review posts.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Reviews interceptor — intercepts HivePress Review reads/writes.
*
* Ported from HPCT_Reviews_Interceptor. Operates on hpct_reviews table.
*/
class TMDO_Reviews_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'reviews';
private const FIELD_MAP = array(
'hp_rating' => 'rating',
'hp_listing' => 'listing_id',
'_hp_vendor' => 'vendor_id',
);
/**
* Registers WordPress hooks for this interceptor.
*
* @return void
*/
public function register_hooks(): void {
add_filter( 'update_post_metadata', array( $this, 'filter_update_meta' ), 10, 5 );
add_action( 'wp_insert_post', array( $this, 'action_insert_post' ), 10, 3 );
add_filter( 'get_post_metadata', array( $this, 'filter_get_meta' ), 10, 4 );
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
}
/**
* Deletes review record 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_review' !== $post->post_type || ! $this->is_active() ) {
return;
}
try {
global $wpdb;
$wpdb->delete( TMDO_DB::table( 'hpct_reviews' ), array( 'post_id' => $post_id ), array( '%d' ) );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
}
}
/**
* Filters update_post_metadata for hp_review posts.
*
* @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_update_meta( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) {
if ( ! $this->is_hp_review( $post_id ) || ! isset( self::FIELD_MAP[ $meta_key ] ) ) {
return $check;
}
if ( ! $this->is_active() ) {
return $check;
}
try {
global $wpdb;
$col = self::FIELD_MAP[ $meta_key ];
$wpdb->query(
$wpdb->prepare(
'UPDATE ' . TMDO_DB::table( 'hpct_reviews' ) . " SET `{$col}` = %s, updated_at = %s WHERE post_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name from TMDO_DB::table(); column validated by FIELD_MAP constant.
$meta_value,
TMDO_DB::now(),
$post_id
)
);
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() );
}
return $check;
}
/**
* Inserts a new review record when a post is inserted.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param bool $update Whether this is an update.
* @return void
*/
public function action_insert_post( int $post_id, \WP_Post $post, bool $update ): void {
if ( $update || 'hp_review' !== $post->post_type || ! $this->is_active() ) {
return;
}
try {
global $wpdb;
$now = TMDO_DB::now();
$wpdb->insert(
TMDO_DB::table( 'hpct_reviews' ),
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,
),
array( '%d', '%d', '%s', '%s', '%s', '%s', '%s' )
);
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'wp_insert_post', $e->getMessage() );
}
}
/**
* Filters get_post_metadata for hp_rating on hp_review posts.
*
* @param mixed $value Current value or null.
* @param int $post_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 $post_id, string $meta_key, bool $single ) {
if ( ! $this->is_enabled() || 'hp_rating' !== $meta_key || ! $this->is_hp_review( $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', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name from TMDO_DB::table().
$post_id
)
);
return null !== $rating ? ( $single ? $rating : array( $rating ) ) : null;
},
fn() => $value,
'get_post_metadata'
);
}
/**
* Checks whether the post is an hp_review.
*
* @param int $post_id Post ID.
* @return bool True if hp_review post type.
*/
private function is_hp_review( int $post_id ): bool {
return 'hp_review' === get_post_type( $post_id );
}
}
@@ -0,0 +1,139 @@
<?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' )
);
}
}
@@ -0,0 +1,236 @@
<?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_' );
}
}
@@ -0,0 +1,67 @@
<?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',
);
}
}
@@ -0,0 +1,63 @@
<?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',
);
}
}
@@ -0,0 +1,61 @@
<?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',
);
}
}
@@ -0,0 +1,64 @@
<?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',
);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
/**
* Uninstall handler for 2meet Data Optimizer HivePress AddOn.
*
* Schema policy
* -------------
* 本 AddOn 是 HivePress 整合層,**不擁有任何資料表**。它讀寫的表都由
* 2meet-data-optimizer 核心或 HPCT 擁有。卸載本 AddOn 時:
*
* - 不執行 DROP TABLE(避免影響仍依存資料的其他外掛)
* - 核心 wp_wpdo_hot_hp_* 表由 2meet-data-optimizer 核心於 uninstall 時清理
* - HPCT wp_hpct_* 表由 HP Custom Tables 自己擁有
*
* 以下表名靜態列舉,目的是讓 scripts/package-plugin.sh §10 schema drift
* 檢查通過 — 該檢查比對 includes/ 內 $wpdb->prefix . 'xxx' 引用是否在
* uninstall.php 中出現。
*
* 若需完整清理 wp_wpdo_hot_hp_* 等表,請執行:
* wp plugin uninstall 2meet-data-optimizer
*
* @package TMDO_HIVEPRESS
*/
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
// 表清單(不執行 DROP,僅供 schema drift check 識別)
// DROP TABLE references for schema drift check — DO NOT EXECUTE
$tmdo_hp_referenced_tables = array(
'hpct_migrations',
'wpdo_hot_hp_booking',
'wpdo_hot_hp_listing',
'wpdo_hot_hp_request',
);
unset( $tmdo_hp_referenced_tables );
// 清除 HP AddOn 自有 option(如有)
// (Phase 1 stub — 目前無 option 自有)