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:
+16
@@ -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/
|
||||
@@ -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 );
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)。
|
||||
@@ -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_*` 命名空間
|
||||
@@ -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 起記錄)
|
||||
@@ -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
@@ -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
|
||||
|
||||
- (本版本暫無已知安全 risk;Phase 3 ship 時補)
|
||||
@@ -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 <addon-slug>
|
||||
wp wpdo hivepress rollback <addon-slug>
|
||||
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 )
|
||||
@@ -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 )
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 8–16 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
|
||||
}
|
||||
}
|
||||
@@ -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 )
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 3–6 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.0–1.0) plus an `aggregate` key (0.0–10.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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 自有)
|
||||
Reference in New Issue
Block a user