hardening 2.0: 2meet-data-optimizer (staged rollout, dev22 first) #3
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: 2meet Data Optimizer
|
* Plugin Name: 2meet Data Optimizer
|
||||||
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
||||||
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
||||||
* Version: 1.0.0
|
* Version: 1.0.2
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Tested up to: 6.9.4
|
* Tested up to: 6.9.4
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
define( 'TMDO_VERSION', '1.0.0' );
|
define( 'TMDO_VERSION', '1.0.2' );
|
||||||
define( 'TMDO_DB_VERSION', '2.1.0' );
|
define( 'TMDO_DB_VERSION', '2.1.0' );
|
||||||
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
||||||
@@ -258,6 +258,8 @@ require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-cleanup.php
|
|||||||
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
|
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
|
||||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
|
||||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
|
||||||
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
|
||||||
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
|
||||||
add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) );
|
add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) );
|
||||||
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
|
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
|
||||||
add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
|
add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
|
||||||
@@ -266,6 +268,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
|
|||||||
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
|
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
|
||||||
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
|
||||||
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
|
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
|
||||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
|
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
|
||||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
|
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
|
||||||
@@ -285,6 +288,10 @@ if ( is_admin() ) {
|
|||||||
TMDO_Setup_Wizard::register();
|
TMDO_Setup_Wizard::register();
|
||||||
require_once TMDO_PATH . 'admin/class-tmdo-dashboard-widget.php';
|
require_once TMDO_PATH . 'admin/class-tmdo-dashboard-widget.php';
|
||||||
TMDO_Dashboard_Widget::register();
|
TMDO_Dashboard_Widget::register();
|
||||||
|
require_once TMDO_PATH . 'admin/class-tmdo-deprecated-addons-notice.php';
|
||||||
|
TMDO_Deprecated_Addons_Notice::register();
|
||||||
|
require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php';
|
||||||
|
TMDO_Missing_Addons_Notice::register();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WP-CLI ─────────────────────────────────────────────────────────────────
|
// ── WP-CLI ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -7,6 +7,81 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.0.2] — 2026-08-15 — NinjaFirewall 相容性:WAF 設定選項保護
|
||||||
|
|
||||||
|
**性質**:防禦性修正 + 文件。無 schema 變更,無破壞性變更。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`docs/WAF-COMPATIBILITY.md`** — NinjaFirewall (WP Edition) 4.9 相容性評估
|
||||||
|
|
||||||
|
結論:兩者可共存,**不需要開發 AddOn**(NinjaFirewall 全 codebase 零個
|
||||||
|
`apply_filters('nfw_*')` / `do_action('nfw_*')`,官方相容手段全在部署層)。
|
||||||
|
文件涵蓋 WP WAF 與 Full WAF 的模式差異、symlink 多租戶部署的必要設定、
|
||||||
|
三條開發約束,以及實測風險矩陣。
|
||||||
|
|
||||||
|
- **`TMDO_Options_Manager::PROTECTED_OPTIONS`** 常數與註冊守衛
|
||||||
|
(`modules/options/class-tmdo-options-manager.php`)
|
||||||
|
|
||||||
|
`register_settings_group()` 會以 `pre_update_option_{key}` 回傳 `$old_value`,
|
||||||
|
讓選項不再落地 `wp_options`、改存專屬設定表。但 NinjaFirewall 的 Full WAF 走
|
||||||
|
`auto_prepend_file`,在 WordPress 載入前就以**原生 mysqli 直查 `wp_options`**
|
||||||
|
取 `nfw_options` / `nfw_rules`。一旦這些鍵被重導向,WAF 會讀不到設定而
|
||||||
|
**靜默停止防護 —— 不報錯、不寫 log**。
|
||||||
|
|
||||||
|
現以 `PROTECTED_OPTIONS`(`nfw_options` / `nfw_rules` / `nfw_checked`)在註冊
|
||||||
|
階段擋下,並發出 `_doing_it_wrong()`。守衛置於方法開頭,全部鍵都被擋時提前
|
||||||
|
返回,不再建立空的設定表。
|
||||||
|
|
||||||
|
註:autoload 最佳化不受影響 —— `optimize_autoload()` 只改 `autoload` 欄位、
|
||||||
|
不刪列,而該處的 `SELECT *` 不看 autoload。
|
||||||
|
|
||||||
|
- **`tests/unit/OptionsManagerProtectedTest.php`** — 4 tests / 10 assertions
|
||||||
|
|
||||||
|
鎖住「受保護鍵絕不會被掛上 `pre_option_*` / `pre_update_option_*` 攔截」這個
|
||||||
|
核心保證,並驗證全數受保護時不對資料庫發出查詢。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Migration Wizard 輪詢間隔 500ms → 2s**(`admin/assets/wpdo-migration-wizard.js`)
|
||||||
|
|
||||||
|
原本 2 req/s 打同一個 REST endpoint,容易觸發 WAF 的 rate-limit 與 bot 偵測。
|
||||||
|
改為 2 秒,與四個 stress-test 面板既有的輪詢節奏一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.1] — 2026-08-08 — Schema Registry 冪等性修正(cold zone 欄位重複累積)
|
||||||
|
|
||||||
|
**性質**:核心 bug 修復。無 schema 變更,無 API 變更,無破壞性變更。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`TMDO_Schema_Registry::register()` 的 cold zone 分支會無限累積重複項**
|
||||||
|
(`includes/class-tmdo-schema-registry.php`)
|
||||||
|
|
||||||
|
三個 zone 索引的寫入方式並不一致:
|
||||||
|
- `hot` → `$this->hot_columns[$post_type][$column] = ...`(以 column 為 key,天生冪等)
|
||||||
|
- `warm` → `$this->warm_fields[$meta_key] = ...`(以 meta_key 為 key,天生冪等)
|
||||||
|
- `cold` → `$this->cold_fields[$post_type][] = ...`(**append,無去重**)
|
||||||
|
|
||||||
|
而 `wpdo_register_fields` 本來就會被觸發多次——核心自身在 `plugins_loaded:4` 觸發,
|
||||||
|
主檔另有 late-bind safety net 於 `plugins_loaded:30` 再次觸發,好讓在 `:6` 之後才
|
||||||
|
bootstrap 的 AddOn 也能趕上;再加上主外掛與其 AddOn 可能同時註冊同一批欄位。
|
||||||
|
|
||||||
|
**實測影響**:某站台 `cold_fields['hp_vendor']` 累積到 **35 筆但只有 9 個相異值**,
|
||||||
|
每個 key 重複 4 次。修正後為 9 筆、零重複。
|
||||||
|
|
||||||
|
修法:cold 分支加入 `in_array( ..., true )` 去重,使 `register()` 對同一
|
||||||
|
`(post_type, meta_key)` 成為冪等操作,與 hot / warm 的既有行為一致。
|
||||||
|
|
||||||
|
### 驗證
|
||||||
|
|
||||||
|
- 核心單元測試 **587 tests / 1156 assertions** 全綠
|
||||||
|
- `wp wpdo doctor` 於兩個站台皆 0 FAIL
|
||||||
|
- Version bump 1.0.0 → 1.0.1(2 點一致:header / `TMDO_VERSION`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.0.0] — 2026-07-31 — 取代 wp-data-optimizer v3.4.6
|
## [1.0.0] — 2026-07-31 — 取代 wp-data-optimizer v3.4.6
|
||||||
|
|
||||||
本版把 `wp-data-optimizer` v3.0.1–v3.4.6(92 個 commit)全數 backport 進來,
|
本版把 `wp-data-optimizer` v3.0.1–v3.4.6(92 個 commit)全數 backport 進來,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **Version**: 0.1.0 (scaffold + 4 phase 完成 2026-05-15)
|
- **Version**: 1.0.2 (2026-08-15;scaffold + 4 phase 完成於 2026-05-15)
|
||||||
- **Phase**: 全 4 phase 已完成
|
- **Phase**: 全 4 phase 已完成
|
||||||
- **Source**: 從 `wp-data-optimizer v2.16.0` 提煉
|
- **Source**: 從 `wp-data-optimizer v2.16.0` 提煉
|
||||||
|
|
||||||
@@ -287,3 +287,28 @@ AddOn 測試 bootstrap 直接 `require` 核心 plugin 的 `tests/bootstrap.php`
|
|||||||
- `v0.1.0` — 本 release(Phase 0-4 完成)
|
- `v0.1.0` — 本 release(Phase 0-4 完成)
|
||||||
- `v0.1.1` — Phase 5 follow-up(tests / PHPCS / packaging)
|
- `v0.1.1` — Phase 5 follow-up(tests / PHPCS / packaging)
|
||||||
- `v0.2.0` — WPDO_ deprecation notice + wpdo → tmdo migration CLI
|
- `v0.2.0` — WPDO_ deprecation notice + wpdo → tmdo migration CLI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NinjaFirewall 相容性 ✅(2026-08-15,v1.0.2)
|
||||||
|
|
||||||
|
計畫檔:`~/.claude/plans/2meet-data-optimizer-ninjafirewall-effervescent-lighthouse.md`
|
||||||
|
|
||||||
|
- [x] 評估與 NinjaFirewall 4.9 的相容性 → **不需要開發 AddOn**
|
||||||
|
- [x] 診斷 `Cannot retrieve user options from database (#3)` → 與本外掛無關,根因為 symlink 部署
|
||||||
|
- [x] 建立 `/var/www/Studio/.htninja`(symlink-safe 站台錨點,全租戶共用)
|
||||||
|
- [x] dev30 啟用 ninjafirewall,補上缺失的 `nfw_options` / `nfw_rules`
|
||||||
|
- [x] `TMDO_Options_Manager::PROTECTED_OPTIONS` 守衛 + 4 個單元測試
|
||||||
|
- [x] Migration Wizard 輪詢 500ms → 2s
|
||||||
|
- [x] `docs/WAF-COMPATIBILITY.md`
|
||||||
|
- [x] 全套件回歸:591 tests / 1166 assertions OK
|
||||||
|
|
||||||
|
### Lessons learned(WAF)
|
||||||
|
|
||||||
|
- **`__DIR__` 會解析 symlink**。NinjaFirewall Full WAF 用 `dirname(dirname(dirname(__DIR__)))`(`lib/firewall.php:78`)推導站台位置,在 symlink 共享 codebase 的 SaaS 架構下,所有租戶都會被判定成「共享目錄所屬的那個站」,於是連錯資料庫、撈不到自己的 `nfw_options`,回錯誤碼 6(訊息寫作 `#3`)。**per-site 的可靠錨點是 `$_SERVER['DOCUMENT_ROOT']`**(web server 的 root 指令值,不受 symlink 影響),這正是 `.htninja` 的搜尋基準。
|
||||||
|
- **`.htninja` 放在 `dirname(DOCUMENT_ROOT)` 可服務全部租戶**(`firewall.php:47-48` 的第二順位),因為內容以 DOCUMENT_ROOT 動態推導,一份檔案通用。切勿在其中寫 `return` —— `'ALLOW'` / `'BLOCK'` 是有意義的回傳值。
|
||||||
|
- **`NFW_LOG_DIR` 對 WP WAF 模式同樣必要**:L78 的誤判在兩種模式都存在,只是 WP WAF 不用它連 DB,但 log / cache / session 仍會全部寫進共享目錄互相覆蓋。
|
||||||
|
- **WAF 失效是靜默的**。讀不到設定時 `nfw_quit()` 直接返回,不擋任何請求也不寫 log —— 「your site is not protected」是字面意思。因此任何會讓 `nfw_options` 離開 `wp_options` 的機制(例如本外掛的 `register_settings_group()` 重導向)都必須擋在註冊階段。
|
||||||
|
- **`.user.ini` 必須讓 php-fpm worker 可讀**。dev32 那份是 `root:root 0640`,www-data 讀不到 → `auto_prepend_file` 靜默失效、Full WAF 等同沒裝。排查時容易誤判成「WAF 有在跑」,實際上擋下請求的是共享 mu-plugin 的 WP WAF。
|
||||||
|
- **判斷 WAF 是否真的生效,要用會被規則擋的請求實測**(例如 `?x=../../etc/passwd` → 規則 1 → 403),光看首頁 200 或後台無錯誤訊息都不算數。
|
||||||
|
- WP-CLI 完全豁免(`firewall.php:18-23`),所以 CLI 全綠**不能**當作「WAF 與外掛相容」的證據,必須另外走 HTTP 驗證。
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* - Confirms options + checkbox before starting.
|
* - Confirms options + checkbox before starting.
|
||||||
* - Calls REST endpoints under /wp-json/wpdo/v1/migration/.
|
* - Calls REST endpoints under /wp-json/wpdo/v1/migration/.
|
||||||
* - Polls /status every 500ms while job is active.
|
* - Polls /status every 2s while job is active.
|
||||||
* - Streams log lines with fade-in; live-updates ratio + progress bar.
|
* - Streams log lines with fade-in; live-updates ratio + progress bar.
|
||||||
*
|
*
|
||||||
* @since 2.8.0
|
* @since 2.8.0
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 500;
|
const POLL_INTERVAL_MS = 2000;
|
||||||
const config = window.wpdoMigrationWizard || {};
|
const config = window.wpdoMigrationWizard || {};
|
||||||
const i18n = config.i18n || {};
|
const i18n = config.i18n || {};
|
||||||
const restUrl = (config.restUrl || '').replace(/\/$/, '');
|
const restUrl = (config.restUrl || '').replace(/\/$/, '');
|
||||||
|
|||||||
+17
-109
@@ -783,11 +783,6 @@ class TMDO_Admin {
|
|||||||
$tab = 'dashboard';
|
$tab = 'dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show HPCT Import tab only when HPCT is detected.
|
|
||||||
if ( TMDO_Compatibility::should_show_hpct_notice() ) {
|
|
||||||
$tabs['hpct-import'] = __( 'HPCT 匯入', '2meet-data-optimizer' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// v2.8.1: red-dot badge on tabs that need operator attention.
|
// v2.8.1: red-dot badge on tabs that need operator attention.
|
||||||
$needs_dot = array();
|
$needs_dot = array();
|
||||||
if ( class_exists( 'TMDO_Migration_Orchestrator' ) ) {
|
if ( class_exists( 'TMDO_Migration_Orchestrator' ) ) {
|
||||||
@@ -811,7 +806,7 @@ class TMDO_Admin {
|
|||||||
),
|
),
|
||||||
'wizards' => array(
|
'wizards' => array(
|
||||||
'label' => esc_html__( '🧙 遷移精靈', '2meet-data-optimizer' ),
|
'label' => esc_html__( '🧙 遷移精靈', '2meet-data-optimizer' ),
|
||||||
'tabs' => array( 'migration-wizard', 'post-migration-wizard', 'hpct-import' ),
|
'tabs' => array( 'migration-wizard', 'post-migration-wizard' ),
|
||||||
),
|
),
|
||||||
'stress' => array(
|
'stress' => array(
|
||||||
'label' => esc_html__( '⚡ 壓力測試', '2meet-data-optimizer' ),
|
'label' => esc_html__( '⚡ 壓力測試', '2meet-data-optimizer' ),
|
||||||
@@ -939,9 +934,6 @@ class TMDO_Admin {
|
|||||||
case 'rest-api':
|
case 'rest-api':
|
||||||
self::render_rest_api();
|
self::render_rest_api();
|
||||||
break;
|
break;
|
||||||
case 'hpct-import':
|
|
||||||
self::render_hpct_import();
|
|
||||||
break;
|
|
||||||
case 'hivepress':
|
case 'hivepress':
|
||||||
if ( class_exists( 'TMDO_Admin_HivePress' ) ) {
|
if ( class_exists( 'TMDO_Admin_HivePress' ) ) {
|
||||||
TMDO_Admin_HivePress::render();
|
TMDO_Admin_HivePress::render();
|
||||||
@@ -1121,20 +1113,6 @@ class TMDO_Admin {
|
|||||||
<th><?php esc_html_e( 'HivePress', '2meet-data-optimizer' ); ?></th>
|
<th><?php esc_html_e( 'HivePress', '2meet-data-optimizer' ); ?></th>
|
||||||
<td><?php echo wp_kses_post( $compat['hivepress'] ? '<span class="wpdo-badge wpdo-badge-ok">Active</span>' : '<span class="wpdo-badge">N/A</span>' ); ?></td>
|
<td><?php echo wp_kses_post( $compat['hivepress'] ? '<span class="wpdo-badge wpdo-badge-ok">Active</span>' : '<span class="wpdo-badge">N/A</span>' ); ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<th><?php esc_html_e( 'HP Custom Tables', '2meet-data-optimizer' ); ?></th>
|
|
||||||
<td>
|
|
||||||
<?php
|
|
||||||
if ( $compat['hpct_active'] && $compat['hpct_imported'] ) {
|
|
||||||
echo '<span class="wpdo-badge wpdo-badge-ok">Imported</span>';
|
|
||||||
} elseif ( $compat['hpct_active'] ) {
|
|
||||||
echo '<span class="wpdo-badge wpdo-badge-warn">Needs Import</span>';
|
|
||||||
} else {
|
|
||||||
echo '<span class="wpdo-badge">N/A</span>';
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e( 'Object Cache', '2meet-data-optimizer' ); ?></th>
|
<th><?php esc_html_e( 'Object Cache', '2meet-data-optimizer' ); ?></th>
|
||||||
<td>
|
<td>
|
||||||
@@ -1169,11 +1147,11 @@ class TMDO_Admin {
|
|||||||
<h2><?php esc_html_e( '模組狀態', '2meet-data-optimizer' ); ?></h2>
|
<h2><?php esc_html_e( '模組狀態', '2meet-data-optimizer' ); ?></h2>
|
||||||
<div class="wpdo-grid">
|
<div class="wpdo-grid">
|
||||||
<div>
|
<div>
|
||||||
<h3>HPCT Modules</h3>
|
<h3>AddOn Modules</h3>
|
||||||
<table class="widefat striped">
|
<table class="widefat striped">
|
||||||
<thead><tr><th><?php esc_html_e( '模組', '2meet-data-optimizer' ); ?></th><th><?php esc_html_e( '狀態', '2meet-data-optimizer' ); ?></th></tr></thead>
|
<thead><tr><th><?php esc_html_e( '模組', '2meet-data-optimizer' ); ?></th><th><?php esc_html_e( '狀態', '2meet-data-optimizer' ); ?></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ( TMDO_Feature_Flags::hpct_modules() as $module => $state ) : ?>
|
<?php foreach ( TMDO_Feature_Flags::addon_modules() as $module => $state ) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code><?php echo esc_html( $module ); ?></code></td>
|
<td><code><?php echo esc_html( $module ); ?></code></td>
|
||||||
<td><span class="wpdo-state wpdo-state-<?php echo esc_attr( $state ); ?>"><?php echo esc_html( $state ); ?></span></td>
|
<td><span class="wpdo-state wpdo-state-<?php echo esc_attr( $state ); ?>"><?php echo esc_html( $state ); ?></span></td>
|
||||||
@@ -2717,79 +2695,6 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
|||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* HPCT Import tab: preview and execute import.
|
|
||||||
*/
|
|
||||||
private static function render_hpct_import(): void {
|
|
||||||
// HPCT is a HivePress-family plugin; the importer ships in that AddOn.
|
|
||||||
if ( ! class_exists( 'TMDO_HPCT_Import' ) ) {
|
|
||||||
echo '<div class="notice notice-warning"><p>'
|
|
||||||
. esc_html__( 'HPCT 匯入需要啟用 2meet-data-optimizer-hivepress-addon。', '2meet-data-optimizer' )
|
|
||||||
. '</p></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle import action.
|
|
||||||
if ( isset( $_POST['wpdo_run_import'] ) && check_admin_referer( 'wpdo_hpct_import' ) ) {
|
|
||||||
$result = TMDO_HPCT_Import::run();
|
|
||||||
if ( is_wp_error( $result ) ) {
|
|
||||||
echo '<div class="notice notice-error"><p>' . esc_html( $result->get_error_message() ) . '</p></div>';
|
|
||||||
} else {
|
|
||||||
echo '<div class="notice notice-success"><p>' . esc_html__( 'HPCT 匯入成功!建議停用 HP Custom Tables 外掛。', '2meet-data-optimizer' ) . '</p></div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$can_import = TMDO_HPCT_Import::can_import();
|
|
||||||
$is_imported = TMDO_HPCT_Import::is_imported();
|
|
||||||
|
|
||||||
?>
|
|
||||||
<h2><?php esc_html_e( 'HP Custom Tables 匯入', '2meet-data-optimizer' ); ?></h2>
|
|
||||||
|
|
||||||
<?php if ( $is_imported ) : ?>
|
|
||||||
<div class="notice notice-success inline">
|
|
||||||
<p><?php esc_html_e( 'HPCT 設定已匯入完成。如果 HP Custom Tables 外掛仍然啟用,建議停用它。', '2meet-data-optimizer' ); ?></p>
|
|
||||||
</div>
|
|
||||||
<?php elseif ( $can_import ) : ?>
|
|
||||||
<p class="description"><?php esc_html_e( '偵測到 HP Custom Tables 外掛。匯入會將 HPCT 的模組狀態和遷移記錄複製到 WPDO,然後由 WPDO 接管所有攔截器。', '2meet-data-optimizer' ); ?></p>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
$preview_data = TMDO_HPCT_Import::preview();
|
|
||||||
if ( ! empty( $preview_data['modules'] ) ) :
|
|
||||||
$preview = $preview_data['modules'];
|
|
||||||
?>
|
|
||||||
<h3><?php esc_html_e( '匯入預覽', '2meet-data-optimizer' ); ?></h3>
|
|
||||||
<table class="widefat striped wpdo-table--narrow">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th><?php esc_html_e( 'HPCT 模組', '2meet-data-optimizer' ); ?></th>
|
|
||||||
<th><?php esc_html_e( 'HPCT 狀態', '2meet-data-optimizer' ); ?></th>
|
|
||||||
<th><?php esc_html_e( 'WPDO 狀態', '2meet-data-optimizer' ); ?></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ( $preview as $item ) : ?>
|
|
||||||
<tr>
|
|
||||||
<td><code><?php echo esc_html( $item['module'] ); ?></code></td>
|
|
||||||
<td><?php echo esc_html( $item['hpct_status'] ); ?></td>
|
|
||||||
<td><span class="wpdo-state wpdo-state-<?php echo esc_attr( $item['wpdo_state'] ); ?>"><?php echo esc_html( $item['wpdo_state'] ); ?></span></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<form method="post" class="wpdo-mt-3">
|
|
||||||
<?php wp_nonce_field( 'wpdo_hpct_import' ); ?>
|
|
||||||
<?php submit_button( __( '執行匯入', '2meet-data-optimizer' ), 'primary', 'wpdo_run_import', false ); ?>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php else : ?>
|
|
||||||
<div class="notice notice-info inline">
|
|
||||||
<p><?php esc_html_e( '未偵測到 HP Custom Tables 外掛,或已完成匯入。', '2meet-data-optimizer' ); ?></p>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── AJAX handler ──────────────────────────────────────────────────────
|
// ── AJAX handler ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2990,7 +2895,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
|||||||
?>
|
?>
|
||||||
<h2><?php esc_html_e( '健康檢查(Doctor)', '2meet-data-optimizer' ); ?></h2>
|
<h2><?php esc_html_e( '健康檢查(Doctor)', '2meet-data-optimizer' ); ?></h2>
|
||||||
<p class="description">
|
<p class="description">
|
||||||
<?php esc_html_e( '7 項自我診斷檢查:schema 完整性、錯誤預算、hook 衝突、autoload 大小、postmeta 爆量、orphan zone rows、缺少快照。', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '10 項自我診斷檢查:schema 完整性、錯誤預算、hook 衝突、autoload 大小、postmeta 爆量、orphan zone rows、缺少快照、實體群組名稱衝突、孤兒實體群組、缺少對應 AddOn。', '2meet-data-optimizer' ); ?>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a class="button" href="<?php echo esc_url( admin_url( 'site-health.php' ) ); ?>">
|
<a class="button" href="<?php echo esc_url( admin_url( 'site-health.php' ) ); ?>">
|
||||||
@@ -3004,13 +2909,16 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tests = array(
|
$tests = array(
|
||||||
'wpdo_schema_drift' => array( 'check_schema_drift', __( 'Schema 完整性', '2meet-data-optimizer' ) ),
|
'wpdo_schema_drift' => array( 'check_schema_drift', __( 'Schema 完整性', '2meet-data-optimizer' ) ),
|
||||||
'wpdo_error_budget' => array( 'check_error_budget', __( '錯誤預算(過去 7 天)', '2meet-data-optimizer' ) ),
|
'wpdo_error_budget' => array( 'check_error_budget', __( '錯誤預算(過去 7 天)', '2meet-data-optimizer' ) ),
|
||||||
'wpdo_hook_conflicts' => array( 'check_hook_conflicts', __( 'Hook 衝突', '2meet-data-optimizer' ) ),
|
'wpdo_hook_conflicts' => array( 'check_hook_conflicts', __( 'Hook 衝突', '2meet-data-optimizer' ) ),
|
||||||
'wpdo_autoload_bloat' => array( 'check_autoload_bloat', __( 'Autoload 大小', '2meet-data-optimizer' ) ),
|
'wpdo_autoload_bloat' => array( 'check_autoload_bloat', __( 'Autoload 大小', '2meet-data-optimizer' ) ),
|
||||||
'wpdo_postmeta_explosion' => array( 'check_postmeta_explosion', __( 'wp_postmeta 爆量', '2meet-data-optimizer' ) ),
|
'wpdo_postmeta_explosion' => array( 'check_postmeta_explosion', __( 'wp_postmeta 爆量', '2meet-data-optimizer' ) ),
|
||||||
'wpdo_orphan_zone_rows' => array( 'check_orphan_zone_rows', __( 'Orphan zone rows', '2meet-data-optimizer' ) ),
|
'wpdo_orphan_zone_rows' => array( 'check_orphan_zone_rows', __( 'Orphan zone rows', '2meet-data-optimizer' ) ),
|
||||||
'wpdo_missing_snapshot' => array( 'check_missing_snapshot', __( '缺少最近快照', '2meet-data-optimizer' ) ),
|
'wpdo_missing_snapshot' => array( 'check_missing_snapshot', __( '缺少最近快照', '2meet-data-optimizer' ) ),
|
||||||
|
'wpdo_entity_group_collisions' => array( 'check_entity_group_collisions', __( '實體群組名稱衝突', '2meet-data-optimizer' ) ),
|
||||||
|
'wpdo_orphaned_entity_groups' => array( 'check_orphaned_entity_groups', __( '孤兒實體群組', '2meet-data-optimizer' ) ),
|
||||||
|
'wpdo_missing_integration_addons' => array( 'check_missing_integration_addons', __( '缺少對應 AddOn', '2meet-data-optimizer' ) ),
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<table class="widefat striped">
|
<table class="widefat striped">
|
||||||
@@ -3490,7 +3398,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
|||||||
|
|
||||||
<?php
|
<?php
|
||||||
// v2.12.1: Term + Comment garbage write-time filter toggle + status.
|
// v2.12.1: Term + Comment garbage write-time filter toggle + status.
|
||||||
$tcg_enabled = (bool) get_option( 'wpdo_term_comment_garbage_filter_enabled', '1' );
|
$tcg_enabled = (bool) get_option( 'wpdo_term_comment_garbage_filter_enabled', '0' );
|
||||||
$tcg_drops_24h = class_exists( 'TMDO_Term_Comment_Garbage_Filter' )
|
$tcg_drops_24h = class_exists( 'TMDO_Term_Comment_Garbage_Filter' )
|
||||||
? TMDO_Term_Comment_Garbage_Filter::get_drop_count_24h()
|
? TMDO_Term_Comment_Garbage_Filter::get_drop_count_24h()
|
||||||
: 0;
|
: 0;
|
||||||
@@ -3653,7 +3561,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
|||||||
|
|
||||||
<?php
|
<?php
|
||||||
// v2.12.4: Term + Comment misc bucket toggle + status.
|
// v2.12.4: Term + Comment misc bucket toggle + status.
|
||||||
$misc_enabled = (bool) get_option( 'wpdo_term_comment_misc_bucket_enabled', '1' );
|
$misc_enabled = (bool) get_option( 'wpdo_term_comment_misc_bucket_enabled', '0' );
|
||||||
$term_misc_rows = class_exists( 'TMDO_Term_Comment_Misc_Bucket' )
|
$term_misc_rows = class_exists( 'TMDO_Term_Comment_Misc_Bucket' )
|
||||||
? TMDO_Term_Comment_Misc_Bucket::count_rows( 'term' )
|
? TMDO_Term_Comment_Misc_Bucket::count_rows( 'term' )
|
||||||
: 0;
|
: 0;
|
||||||
@@ -3738,7 +3646,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
|||||||
<?php
|
<?php
|
||||||
$blacklist = class_exists( 'TMDO_FSM_Automator' ) ? TMDO_FSM_Automator::blacklist() : array();
|
$blacklist = class_exists( 'TMDO_FSM_Automator' ) ? TMDO_FSM_Automator::blacklist() : array();
|
||||||
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
|
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||||
$all = array_merge( TMDO_Feature_Flags::HPCT_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
|
$all = array_merge( TMDO_Feature_Flags::ADDON_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
|
||||||
echo '<div class="wpdo-details-section">';
|
echo '<div class="wpdo-details-section">';
|
||||||
foreach ( $all as $m ) {
|
foreach ( $all as $m ) {
|
||||||
printf(
|
printf(
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Admin notice for the deprecated in-house partner AddOns.
|
||||||
|
*
|
||||||
|
* The AddOns for 2meet's own plugins (bookings, collab, hub, infocards,
|
||||||
|
* mobile-bridge, playlist, quotation, spoke) only registered tables and fields
|
||||||
|
* on the partner's behalf. Partner plugins now register themselves with the
|
||||||
|
* TMDO_Anti_EAV_Aware trait (docs/PARTNER-REGISTRATION.md), so these AddOns
|
||||||
|
* are deprecated and will be removed. The SSO data needs no AddOn at all:
|
||||||
|
* the hub, spoke and SSO AddOns go without replacement (the `sso` group gave
|
||||||
|
* no measurable gain over usermeta) once its values are written back.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns admins who still run one of the deprecated partner AddOns.
|
||||||
|
*/
|
||||||
|
final class TMDO_Deprecated_Addons_Notice {
|
||||||
|
|
||||||
|
/** In-house plugins whose AddOn is replaced by partner self-registration. */
|
||||||
|
private const SLUGS = array( 'bookings', 'collab', 'hub', 'infocards', 'mobile-bridge', 'playlist', 'quotation', 'spoke', 'sso' );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook the notice.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(): void {
|
||||||
|
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slugs of the deprecated AddOns active on this site (or network-wide).
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function active(): array {
|
||||||
|
$active = (array) get_option( 'active_plugins', array() );
|
||||||
|
if ( is_multisite() ) {
|
||||||
|
$active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) );
|
||||||
|
}
|
||||||
|
return array_values(
|
||||||
|
array_filter(
|
||||||
|
self::SLUGS,
|
||||||
|
static fn( string $slug ): bool => in_array( "2meet-data-optimizer-{$slug}-addon/2meet-data-optimizer-{$slug}-addon.php", $active, true )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the notice for administrators when any deprecated AddOn is active.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$active = self::active();
|
||||||
|
if ( empty( $active ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = implode( ', ', array_map( static fn( string $slug ): string => "2meet-data-optimizer-{$slug}-addon", $active ) );
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-warning"><p><strong>%s</strong> %s</p><p>%s</p></div>',
|
||||||
|
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
|
||||||
|
esc_html(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: comma-separated AddOn plugin slugs */
|
||||||
|
__( '以下 AddOn 已淘汰:%s。對應的 2meet 外掛改為自行向核心註冊資料表與欄位,AddOn 之後會移除。', '2meet-data-optimizer' ),
|
||||||
|
$names
|
||||||
|
)
|
||||||
|
),
|
||||||
|
esc_html__( '改寫方式見 2meet-data-optimizer 外掛內的 docs/PARTNER-REGISTRATION.md;partner 外掛完成自行註冊後即可停用對應 AddOn。', '2meet-data-optimizer' )
|
||||||
|
);
|
||||||
|
if ( array_intersect( $active, array( 'hub', 'spoke', 'sso' ) ) ) {
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-warning"><p>%s</p></div>',
|
||||||
|
esc_html__( 'Hub / Spoke / SSO AddOn 不需替代:2meet-hub-core 與 2meet-spoke-sso 會自行註冊資料表,SSO 值存回 usermeta 即可。停用前先執行 wp tmdo bridge-restore-native user --group=sso --retire。', '2meet-data-optimizer' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ class TMDO_Help_Tabs {
|
|||||||
private static function content_dashboard(): string {
|
private static function content_dashboard(): string {
|
||||||
return '<p>' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '</p>'
|
return '<p>' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '</p>'
|
||||||
. '<ul class="wpdo-list-disc">'
|
. '<ul class="wpdo-list-disc">'
|
||||||
. '<li>' . esc_html__( 'System Overview — DB 引擎、HivePress、HPCT、Object Cache 是否啟用', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( 'System Overview — DB 引擎、HivePress、Object Cache 是否啟用', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( 'Zone 行數統計 — Hot/Warm/Cold/Archive 各自累積多少資料', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( 'Zone 行數統計 — Hot/Warm/Cold/Archive 各自累積多少資料', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( 'Module 狀態 — 每個 module 在 7-state FSM 哪一格', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( 'Module 狀態 — 每個 module 在 7-state FSM 哪一格', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( 'Warm zone live view — 哪些 view counts / TTL 進來、24h 快過期數', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( 'Warm zone live view — 哪些 view counts / TTL 進來、24h 快過期數', '2meet-data-optimizer' ) . '</li>'
|
||||||
@@ -173,7 +173,6 @@ class TMDO_Help_Tabs {
|
|||||||
. '<p>' . esc_html__( '常見原因:', '2meet-data-optimizer' ) . '</p>'
|
. '<p>' . esc_html__( '常見原因:', '2meet-data-optimizer' ) . '</p>'
|
||||||
. '<ul class="wpdo-list-disc">'
|
. '<ul class="wpdo-list-disc">'
|
||||||
. '<li>' . esc_html__( 'Hook Bus 啟用(wpdo_hook_bus_enabled = 1)+ legacy interceptors 還沒卸載', '2meet-data-optimizer' ) . '</li>'
|
. '<li>' . esc_html__( 'Hook Bus 啟用(wpdo_hook_bus_enabled = 1)+ legacy interceptors 還沒卸載', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li>' . esc_html__( 'HPCT (HP Custom Tables) plugin 還沒移除 — 與 WPDO 同時攔截', '2meet-data-optimizer' ) . '</li>'
|
|
||||||
. '</ul>'
|
. '</ul>'
|
||||||
. '<p>' . esc_html__( '解法:先看 conflict-scan 詳情,必要時用 wp wpdo bridge-set off 暫停 Hook Bus 直到清理完。', '2meet-data-optimizer' ) . '</p>';
|
. '<p>' . esc_html__( '解法:先看 conflict-scan 詳情,必要時用 wp wpdo bridge-set off 暫停 Hook Bus 直到清理完。', '2meet-data-optimizer' ) . '</p>';
|
||||||
}
|
}
|
||||||
@@ -184,7 +183,7 @@ class TMDO_Help_Tabs {
|
|||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private static function content_doctor(): string {
|
private static function content_doctor(): string {
|
||||||
return '<p>' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
|
return '<p>' . esc_html__( '10 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
|
||||||
. '<ol class="wpdo-list-disc">'
|
. '<ol class="wpdo-list-disc">'
|
||||||
. '<li><strong>schema_drift</strong> — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>schema_drift</strong> — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li><strong>error_budget</strong> — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>error_budget</strong> — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '</li>'
|
||||||
@@ -193,6 +192,9 @@ class TMDO_Help_Tabs {
|
|||||||
. '<li><strong>postmeta_explosion</strong> — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>postmeta_explosion</strong> — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li><strong>orphan_zone_rows</strong> — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>orphan_zone_rows</strong> — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '<li><strong>missing_snapshot</strong> — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '</li>'
|
. '<li><strong>missing_snapshot</strong> — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '</li>'
|
||||||
|
. '<li><strong>entity_group_collisions</strong> — ' . esc_html__( '同名實體群組被不同外掛以不同欄位註冊(後者被忽略)', '2meet-data-optimizer' ) . '</li>'
|
||||||
|
. '<li><strong>orphaned_entity_groups</strong> — ' . esc_html__( '扁平表還在但已無外掛註冊的群組(已依保存的定義還原以維持可讀)', '2meet-data-optimizer' ) . '</li>'
|
||||||
|
. '<li><strong>missing_integration_addons</strong> — ' . esc_html__( 'HivePress / WooCommerce 已啟用但對應 AddOn 未啟用', '2meet-data-optimizer' ) . '</li>'
|
||||||
. '</ol>'
|
. '</ol>'
|
||||||
. '<p>' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '</p>';
|
. '<p>' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '</p>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Admin notice when HivePress / WooCommerce run without their AddOn.
|
||||||
|
*
|
||||||
|
* Core knows nothing about either plugin; their fields are only optimised by
|
||||||
|
* the matching AddOn. Without it, aeav_only data stays readable only through
|
||||||
|
* the orphan-group restore, and new data is not optimised at all.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns administrators about integrations whose AddOn is not active.
|
||||||
|
*/
|
||||||
|
final class TMDO_Missing_Addons_Notice {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook the notice.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(): void {
|
||||||
|
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the notice for administrators when an integration lacks its AddOn.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function render(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$missing = TMDO_Compatibility::missing_integration_addons();
|
||||||
|
if ( empty( $missing ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = array();
|
||||||
|
foreach ( $missing as $plugin => $addon ) {
|
||||||
|
$lines[] = sprintf( '%s → %s', $plugin, $addon );
|
||||||
|
}
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-warning"><p><strong>%s</strong> %s</p></div>',
|
||||||
|
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
|
||||||
|
esc_html(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: "Plugin → AddOn" pairs */
|
||||||
|
__( '以下外掛已啟用,但對應的 AddOn 沒有啟用,它們的欄位不會被優化:%s。請啟用對應的 AddOn。', '2meet-data-optimizer' ),
|
||||||
|
implode( ';', $lines )
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-17
@@ -259,6 +259,94 @@ class TMDO_CLI_Post {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── post-reconcile-groups ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move post values written under legacy routing into their post type's
|
||||||
|
* group table, then switch on post_type-scoped routing.
|
||||||
|
*
|
||||||
|
* Needed once on sites that already hold post flat rows (the installer
|
||||||
|
* leaves tmdo_post_scoped_routing off there). Resumable: an interrupted
|
||||||
|
* run continues from its cursor.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Count the values that would be copied / overwritten; write nothing.
|
||||||
|
*
|
||||||
|
* [--batch=<n>]
|
||||||
|
* : Rows per batch. Default 500.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo post-reconcile-groups --dry-run
|
||||||
|
* wp tmdo post-reconcile-groups
|
||||||
|
*
|
||||||
|
* @param array<int, string> $args Positional arguments (unused).
|
||||||
|
* @param array<string, string> $assoc_args Named arguments.
|
||||||
|
*/
|
||||||
|
public function post_reconcile_groups( $args, $assoc_args ): void {
|
||||||
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
|
$report = TMDO_Post_Group_Reconciler::run( $dry_run, (int) ( $assoc_args['batch'] ?? 500 ) );
|
||||||
|
|
||||||
|
if ( empty( $report['moves'] ) ) {
|
||||||
|
WP_CLI::log( 'No shared post keys to move.' );
|
||||||
|
}
|
||||||
|
foreach ( $report['moves'] as $m ) {
|
||||||
|
WP_CLI::log( sprintf( '%s → %s %s: %d value(s)%s', $m['source'], $m['target'], $m['key'], $m['copied'], $dry_run ? sprintf( ', %d would overwrite a different value', $m['overwritten'] ) : '' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $dry_run ) {
|
||||||
|
WP_CLI::success( '[dry-run] Nothing written. Re-run without --dry-run to copy and switch on scoped routing.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WP_CLI::success( 'Post groups reconciled; tmdo_post_scoped_routing is on.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── post-status-sync ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add post_status / post_date copies (and their index) to every post
|
||||||
|
* group table and fill them from wp_posts.
|
||||||
|
*
|
||||||
|
* Existing tables only get the columns here, never in a web request:
|
||||||
|
* ALTER on a large table can take seconds. Afterwards status changes keep
|
||||||
|
* the copies current. Re-run after bulk writes that bypass WordPress
|
||||||
|
* (e.g. post-reconcile-groups, post migrations).
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Count rows whose copies are missing or stale; change nothing.
|
||||||
|
*
|
||||||
|
* [--batch=<n>]
|
||||||
|
* : Rows per UPDATE. Default 1000.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo post-status-sync --dry-run
|
||||||
|
* wp tmdo post-status-sync
|
||||||
|
*
|
||||||
|
* @param array<int, string> $args Positional arguments (unused).
|
||||||
|
* @param array<string, string> $assoc_args Named arguments.
|
||||||
|
*/
|
||||||
|
public function post_status_sync( $args, $assoc_args ): void {
|
||||||
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
|
$report = TMDO_Post_Status_Sync::run( $dry_run, (int) ( $assoc_args['batch'] ?? 1000 ) );
|
||||||
|
|
||||||
|
if ( empty( $report ) ) {
|
||||||
|
WP_CLI::log( 'No post group tables.' );
|
||||||
|
}
|
||||||
|
foreach ( $report as $group => $r ) {
|
||||||
|
WP_CLI::log( sprintf( '%s: %d row(s), %d missing or stale%s', $group, $r['rows'], $r['mismatched'], $dry_run ? '' : sprintf( ' → %s', TMDO_Post_Status_Sync::state( $group ) ) ) );
|
||||||
|
}
|
||||||
|
if ( $dry_run ) {
|
||||||
|
WP_CLI::success( '[dry-run] Nothing changed.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WP_CLI::success( 'post_status / post_date copies are in sync.' );
|
||||||
|
}
|
||||||
|
|
||||||
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
|
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -816,23 +904,7 @@ class TMDO_CLI_Post {
|
|||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
private static function group_post_type_for_cli( string $group ): ?string {
|
private static function group_post_type_for_cli( string $group ): ?string {
|
||||||
switch ( $group ) {
|
return TMDO_Post_Fields::post_type_for_group( $group );
|
||||||
case 'attachment':
|
|
||||||
return 'attachment';
|
|
||||||
case 'wc_product':
|
|
||||||
return 'product';
|
|
||||||
case 'hp_listing_core':
|
|
||||||
return 'hp_listing';
|
|
||||||
case 'hp_request_core':
|
|
||||||
return 'hp_request';
|
|
||||||
case 'hp_vendor_core':
|
|
||||||
return 'hp_vendor';
|
|
||||||
case 'nav_menu_item':
|
|
||||||
return 'nav_menu_item';
|
|
||||||
case 'wp_core':
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,3 +925,6 @@ WP_CLI::add_command( 'wpdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchm
|
|||||||
WP_CLI::add_command( 'tmdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
|
WP_CLI::add_command( 'tmdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
|
||||||
WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|
WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|
||||||
WP_CLI::add_command( 'tmdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|
WP_CLI::add_command( 'tmdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|
||||||
|
WP_CLI::add_command( 'wpdo post-reconcile-groups', array( new TMDO_CLI_Post(), 'post_reconcile_groups' ) );
|
||||||
|
WP_CLI::add_command( 'tmdo post-reconcile-groups', array( new TMDO_CLI_Post(), 'post_reconcile_groups' ) );
|
||||||
|
WP_CLI::add_command( 'tmdo post-status-sync', array( new TMDO_CLI_Post(), 'post_status_sync' ) );
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ class TMDO_CLI_Term_Comment {
|
|||||||
WP_CLI::log( sprintf( 'Mode: %s', $term_mode ) );
|
WP_CLI::log( sprintf( 'Mode: %s', $term_mode ) );
|
||||||
WP_CLI::log( sprintf( 'wp_terms: %s', number_format_i18n( $terms_count ) ) );
|
WP_CLI::log( sprintf( 'wp_terms: %s', number_format_i18n( $terms_count ) ) );
|
||||||
WP_CLI::log( sprintf( 'wp_termmeta: %s (1:%s)', number_format_i18n( $termmeta_count ), $terms_count > 0 ? round( $termmeta_count / $terms_count, 2 ) : 'n/a' ) );
|
WP_CLI::log( sprintf( 'wp_termmeta: %s (1:%s)', number_format_i18n( $termmeta_count ), $terms_count > 0 ? round( $termmeta_count / $terms_count, 2 ) : 'n/a' ) );
|
||||||
self::print_flat_row( $wpdb->prefix . 'wpdo_term_hp_taxonomy', 'wpdo_term_hp_taxonomy' );
|
self::print_group_rows( 'term' );
|
||||||
self::print_flat_row( $wpdb->prefix . 'wpdo_term_misc', 'wpdo_term_misc' );
|
self::print_flat_row( $wpdb->prefix . 'wpdo_term_misc', 'wpdo_term_misc' );
|
||||||
|
|
||||||
WP_CLI::log( '' );
|
WP_CLI::log( '' );
|
||||||
@@ -437,7 +437,7 @@ class TMDO_CLI_Term_Comment {
|
|||||||
WP_CLI::log( sprintf( 'Mode: %s', $comment_mode ) );
|
WP_CLI::log( sprintf( 'Mode: %s', $comment_mode ) );
|
||||||
WP_CLI::log( sprintf( 'wp_comments: %s', number_format_i18n( $comments_count ) ) );
|
WP_CLI::log( sprintf( 'wp_comments: %s', number_format_i18n( $comments_count ) ) );
|
||||||
WP_CLI::log( sprintf( 'wp_commentmeta: %s (1:%s)', number_format_i18n( $commentmeta_count ), $comments_count > 0 ? round( $commentmeta_count / $comments_count, 2 ) : 'n/a' ) );
|
WP_CLI::log( sprintf( 'wp_commentmeta: %s (1:%s)', number_format_i18n( $commentmeta_count ), $comments_count > 0 ? round( $commentmeta_count / $comments_count, 2 ) : 'n/a' ) );
|
||||||
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_hp_review', 'wpdo_comment_hp_review' );
|
self::print_group_rows( 'comment' );
|
||||||
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_misc', 'wpdo_comment_misc' );
|
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_misc', 'wpdo_comment_misc' );
|
||||||
|
|
||||||
WP_CLI::log( '' );
|
WP_CLI::log( '' );
|
||||||
@@ -470,6 +470,21 @@ class TMDO_CLI_Term_Comment {
|
|||||||
return 'Both entities at disabled. Promote to dual_write first to enable Hook Bus interception.';
|
return 'Both entities at disabled. Promote to dual_write first to enable Hook Bus interception.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the row count of every registered group table of an entity.
|
||||||
|
*
|
||||||
|
* @param string $entity_type 'term' or 'comment'.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function print_group_rows( string $entity_type ): void {
|
||||||
|
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $type ) {
|
||||||
|
if ( $type === $entity_type ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||||
|
self::print_flat_row( $table, TMDO_TABLE_PREFIX . $entity_type . '_' . $group );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print a flat table's row count (or "(missing)" when not present).
|
* Print a flat table's row count (or "(missing)" when not present).
|
||||||
*
|
*
|
||||||
@@ -647,7 +662,8 @@ class TMDO_CLI_Term_Comment {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$valid_groups = array( 'all', 'hp_taxonomy', 'hp_review' );
|
$registered = TMDO_Term_Comment_Backfill::registered_groups();
|
||||||
|
$valid_groups = array_merge( array( 'all' ), array_keys( $registered ) );
|
||||||
if ( ! in_array( $group, $valid_groups, true ) ) {
|
if ( ! in_array( $group, $valid_groups, true ) ) {
|
||||||
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid_groups ) );
|
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid_groups ) );
|
||||||
}
|
}
|
||||||
@@ -656,8 +672,7 @@ class TMDO_CLI_Term_Comment {
|
|||||||
if ( 'all' === $group ) {
|
if ( 'all' === $group ) {
|
||||||
$results = TMDO_Term_Comment_Backfill::backfill_all( $dry_run );
|
$results = TMDO_Term_Comment_Backfill::backfill_all( $dry_run );
|
||||||
} else {
|
} else {
|
||||||
$entity_type = 'hp_review' === $group ? 'comment' : 'term';
|
$results = array( $group => TMDO_Term_Comment_Backfill::backfill_group( $registered[ $group ], $group, $dry_run ) );
|
||||||
$results = array( $group => TMDO_Term_Comment_Backfill::backfill_group( $entity_type, $group, $dry_run ) );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render table.
|
// Render table.
|
||||||
|
|||||||
@@ -152,6 +152,71 @@ class TMDO_CLI_V2 {
|
|||||||
WP_CLI::success( "Hook Bus: {$state}" );
|
WP_CLI::success( "Hook Bus: {$state}" );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an entity's flat-table values back to native meta — the way out of
|
||||||
|
* aeav_only. Resumable; re-run after an interruption.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* <entity>
|
||||||
|
* : post | user | term | comment
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Count the flat rows per group; write nothing.
|
||||||
|
*
|
||||||
|
* [--batch=<n>]
|
||||||
|
* : Rows per batch. Default 500.
|
||||||
|
*
|
||||||
|
* [--demote]
|
||||||
|
* : After a complete run, switch the entity to dual_write (both copies kept in step).
|
||||||
|
*
|
||||||
|
* [--group=<group>]
|
||||||
|
* : Only this group.
|
||||||
|
*
|
||||||
|
* [--retire]
|
||||||
|
* : With --group, after the write-back: forget the group (registry_meta row)
|
||||||
|
* and rename its flat table to <table>_retired_<Ymd>. Refused while a plugin
|
||||||
|
* still registers the group or native meta differs from the flat table.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo bridge-restore-native user --dry-run
|
||||||
|
* wp tmdo bridge-restore-native user --demote
|
||||||
|
* wp tmdo bridge-restore-native user --group=sso --retire
|
||||||
|
*
|
||||||
|
* @param array<int, string> $args Positional arguments.
|
||||||
|
* @param array<string, string> $assoc_args Named arguments.
|
||||||
|
*/
|
||||||
|
public function bridge_restore_native( $args, $assoc_args ): void {
|
||||||
|
$entity = (string) ( $args[0] ?? '' );
|
||||||
|
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
||||||
|
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--batch=<n>] [--demote] [--group=<group> [--retire]]' );
|
||||||
|
}
|
||||||
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
|
$group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) );
|
||||||
|
if ( isset( $assoc_args['retire'] ) && '' === $group ) {
|
||||||
|
WP_CLI::error( '--retire needs --group=<group>.' );
|
||||||
|
}
|
||||||
|
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group );
|
||||||
|
|
||||||
|
foreach ( $report['groups'] as $group => $g ) {
|
||||||
|
WP_CLI::log( sprintf( '%s/%s: %d row(s)%s', $entity, $group, $g['rows'], $dry_run ? '' : sprintf( ', %d native value(s) written', $g['written'] ) ) );
|
||||||
|
}
|
||||||
|
if ( $dry_run ) {
|
||||||
|
WP_CLI::success( '[dry-run] Nothing written.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( isset( $assoc_args['retire'] ) ) {
|
||||||
|
$retired = TMDO_Native_Restorer::retire( $entity, $group );
|
||||||
|
if ( 'retired' !== $retired['status'] ) {
|
||||||
|
WP_CLI::error( sprintf( 'Not retired (%s%s). Written values stay in native meta.', $retired['status'], $retired['mismatches'] ? ", {$retired['mismatches']} value(s) differ" : '' ) );
|
||||||
|
}
|
||||||
|
WP_CLI::success( "{$entity}/{$group} retired; flat table kept as {$retired['archived_table']}." );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit per-module state + shadow_read flags.
|
* Audit per-module state + shadow_read flags.
|
||||||
*
|
*
|
||||||
@@ -784,6 +849,8 @@ WP_CLI::add_command( 'wpdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status'
|
|||||||
WP_CLI::add_command( 'tmdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) );
|
WP_CLI::add_command( 'tmdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) );
|
||||||
WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
|
WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
|
||||||
WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
|
WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
|
||||||
|
WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
|
||||||
|
WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
|
||||||
WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
|
WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
|
||||||
WP_CLI::add_command( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
|
WP_CLI::add_command( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
|
||||||
WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) );
|
WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) );
|
||||||
|
|||||||
+38
-66
@@ -42,7 +42,6 @@ class TMDO_CLI {
|
|||||||
|
|
||||||
$compat = TMDO_Compatibility::check();
|
$compat = TMDO_Compatibility::check();
|
||||||
WP_CLI::log( 'HivePress: ' . ( $compat['hivepress'] ? 'Active' : 'Not found' ) );
|
WP_CLI::log( 'HivePress: ' . ( $compat['hivepress'] ? 'Active' : 'Not found' ) );
|
||||||
WP_CLI::log( 'HP Custom Tables: ' . self::format_hpct_status( $compat ) );
|
|
||||||
WP_CLI::log( 'Object Cache: ' . ( wp_using_ext_object_cache() ? 'External' : 'Built-in' ) );
|
WP_CLI::log( 'Object Cache: ' . ( wp_using_ext_object_cache() ? 'External' : 'Built-in' ) );
|
||||||
WP_CLI::log( '' );
|
WP_CLI::log( '' );
|
||||||
|
|
||||||
@@ -57,10 +56,10 @@ class TMDO_CLI {
|
|||||||
|
|
||||||
WP_CLI::log( 'Module States:' );
|
WP_CLI::log( 'Module States:' );
|
||||||
|
|
||||||
$hpct_flags = TMDO_Feature_Flags::hpct_modules();
|
$addon_flags = TMDO_Feature_Flags::addon_modules();
|
||||||
if ( ! empty( $hpct_flags ) ) {
|
if ( ! empty( $addon_flags ) ) {
|
||||||
WP_CLI::log( ' [HPCT Modules]' );
|
WP_CLI::log( ' [AddOn Modules]' );
|
||||||
foreach ( $hpct_flags as $module => $state ) {
|
foreach ( $addon_flags as $module => $state ) {
|
||||||
WP_CLI::log( " {$module}: {$state}" );
|
WP_CLI::log( " {$module}: {$state}" );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,6 +329,40 @@ class TMDO_CLI {
|
|||||||
WP_CLI::log( ' [OK] Crypto key derivable (AUTH_KEY / SECURE_AUTH_SALT present)' );
|
WP_CLI::log( ' [OK] Crypto key derivable (AUTH_KEY / SECURE_AUTH_SALT present)' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Entity group name collisions ─────────────────────────────────────────
|
||||||
|
$collisions = TMDO_Entity_Registry::get_collisions();
|
||||||
|
if ( empty( $collisions ) ) {
|
||||||
|
WP_CLI::log( ' [OK] No entity group name collisions' );
|
||||||
|
}
|
||||||
|
foreach ( $collisions as $c ) {
|
||||||
|
WP_CLI::warning(
|
||||||
|
sprintf(
|
||||||
|
' [COLLISION] %s/%s registered twice with different fields (second ignored) — added: %s; removed: %s; changed: %s',
|
||||||
|
$c['entity_type'],
|
||||||
|
$c['group'],
|
||||||
|
implode( ', ', $c['added'] ) ?: '-',
|
||||||
|
implode( ', ', $c['removed'] ) ?: '-',
|
||||||
|
implode( ', ', $c['changed'] ) ?: '-'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$all_ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orphaned entity groups (restored from wpdo_registry_meta) ───────────
|
||||||
|
foreach ( TMDO_Entity_Registry::get_restored_groups() as $r ) {
|
||||||
|
WP_CLI::warning(
|
||||||
|
sprintf(
|
||||||
|
' [ORPHAN] %s/%s — no active plugin registers it; restored %d field(s) from wpdo_registry_meta%s%s. Reactivate the owner or write the data back before removing it.',
|
||||||
|
$r['entity_type'],
|
||||||
|
$r['group'],
|
||||||
|
$r['fields'],
|
||||||
|
$r['skipped'] ? ' (skipped, owned by another group: ' . implode( ', ', $r['skipped'] ) . ')' : '',
|
||||||
|
$r['native'] ? ' (left on native meta, which has newer data: ' . implode( ', ', $r['native'] ) . ')' : ''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$all_ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Check recent errors.
|
// Check recent errors.
|
||||||
$errors = TMDO_Logger::get_recent( '', 5 );
|
$errors = TMDO_Logger::get_recent( '', 5 );
|
||||||
if ( ! empty( $errors ) ) {
|
if ( ! empty( $errors ) ) {
|
||||||
@@ -634,51 +667,6 @@ class TMDO_CLI {
|
|||||||
WP_CLI::success( "Module {$module} disabled (idle)." );
|
WP_CLI::success( "Module {$module} disabled (idle)." );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Import settings from HP Custom Tables.
|
|
||||||
*
|
|
||||||
* ## EXAMPLES
|
|
||||||
*
|
|
||||||
* wp wpdo import-hpct
|
|
||||||
*
|
|
||||||
* @param array $args Positional arguments.
|
|
||||||
* @param array $assoc_args Associative arguments.
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @subcommand import-hpct
|
|
||||||
*/
|
|
||||||
public function import_hpct( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
|
|
||||||
// HPCT is a HivePress-family plugin; the importer ships in that AddOn.
|
|
||||||
if ( ! class_exists( 'TMDO_HPCT_Import' ) ) {
|
|
||||||
WP_CLI::error( 'import-hpct needs 2meet-data-optimizer-hivepress-addon to be active.' );
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( TMDO_HPCT_Import::is_imported() ) {
|
|
||||||
WP_CLI::warning( 'HPCT settings have already been imported.' );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! TMDO_HPCT_Import::can_import() ) {
|
|
||||||
WP_CLI::error( 'HP Custom Tables plugin not detected or already imported.' );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show preview.
|
|
||||||
$preview_data = TMDO_HPCT_Import::preview();
|
|
||||||
if ( ! empty( $preview_data['modules'] ) ) {
|
|
||||||
WP_CLI::log( 'Import preview:' );
|
|
||||||
WP_CLI\Utils\format_items( 'table', $preview_data['modules'], array( 'module', 'hpct_status', 'wpdo_state' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
WP_CLI::confirm( 'Proceed with import?' );
|
|
||||||
|
|
||||||
$result = TMDO_HPCT_Import::run();
|
|
||||||
if ( is_wp_error( $result ) ) {
|
|
||||||
WP_CLI::error( $result->get_error_message() );
|
|
||||||
}
|
|
||||||
|
|
||||||
WP_CLI::success( 'HPCT settings imported successfully. You can now deactivate HP Custom Tables.' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run performance benchmark.
|
* Run performance benchmark.
|
||||||
*
|
*
|
||||||
@@ -1396,22 +1384,6 @@ class TMDO_CLI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the HPCT status string for display.
|
|
||||||
*
|
|
||||||
* @param array $compat Compatibility check result array.
|
|
||||||
* @return string Human-readable HPCT status.
|
|
||||||
*/
|
|
||||||
private static function format_hpct_status( array $compat ): string {
|
|
||||||
if ( $compat['hpct_active'] && $compat['hpct_imported'] ) {
|
|
||||||
return 'Active (Imported)';
|
|
||||||
}
|
|
||||||
if ( $compat['hpct_active'] ) {
|
|
||||||
return 'Active (Not imported — run `wp wpdo import-hpct`)';
|
|
||||||
}
|
|
||||||
return 'Not found';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the appropriate migration instance for a module name.
|
* Create the appropriate migration instance for a module name.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 規模效能基準:原生 postmeta vs Hot 表 vs Entity 表
|
||||||
|
|
||||||
|
**量測日期**:2026-09-25 **分支**:`hardening/2.0` **目標規模**:vendor 100,000、listing 1,000,000
|
||||||
|
|
||||||
|
## 如何重現
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# DB 帳密沿用整合測試的 TMDO_TEST_DB_* 環境變數;資料庫名稱必須以 _bench 結尾
|
||||||
|
TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/seed.php --vendors=100000 --listings=1000000 # 約 7.5 分鐘
|
||||||
|
TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/run.php --iterations=20 # 約 20 分鐘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 環境與資料
|
||||||
|
|
||||||
|
| 項目 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 資料庫 | MariaDB 10.11.14(本機,InnoDB 預設設定) |
|
||||||
|
| 主機 | 8 核、31 GB RAM、NVMe |
|
||||||
|
| 資料 | 100,000 vendor(users + hp_vendor posts)、1,000,000 listing(90% publish) |
|
||||||
|
| 原生 meta | postmeta 9,549,796 筆(951 MB)、usermeta 600,000 筆 |
|
||||||
|
| Entity 表 | `wpdo_post_hp_listing_core` 1,000,000 筆(379 MB) |
|
||||||
|
| Hot 表 | `wpdo_hot_hp_listing` 1,000,000 筆(229 MB) |
|
||||||
|
|
||||||
|
SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個條件一個 INNER JOIN、數值用 CAST、`SQL_CALC_FOUND_ROWS`),Hot 是 `TMDO_Query_Router`,Entity 是 `TMDO_Post_Query_Router`。每個情境 20 次,另有 3 次暖機不計入,參數隨機但可重現(`--seed=7`)。寫入在交易內執行後 rollback。
|
||||||
|
|
||||||
|
> 20 次取樣下 p95 等於最大值,僅供參考。
|
||||||
|
|
||||||
|
## 結果
|
||||||
|
|
||||||
|
| 情境 | 形式 | p50 ms | p95 ms | 結果筆數 | 使用索引 |
|
||||||
|
|---|---|---:|---:|---:|---|
|
||||||
|
| 1. 價格區間搜尋(命中約 20%) | 原生 | **14,695** | 14,890 | 180,292 | postmeta:post_id |
|
||||||
|
| | Hot | 558 | 3,605 | 180,329 | h:ALL |
|
||||||
|
| | Entity | 652 | 3,817 | 180,300 | e:ALL |
|
||||||
|
| 2. 價格區間 + verified=1 | 原生 | **17,094** | 17,250 | 54,197 | postmeta:post_id ×2 |
|
||||||
|
| | Hot | 277 | 299 | 54,109 | `idx_hp_verified_hp_price`(複合) |
|
||||||
|
| | Entity | 337 | 340 | 54,180 | e:ALL |
|
||||||
|
| 3. 某 vendor 名下的 listing | `post_parent`(HivePress 實際做法) | 0.19 | 0.25 | 7 | posts:post_parent |
|
||||||
|
| | 原生 meta `hp_vendor` | **14,432** | 14,599 | 8 | postmeta:post_id |
|
||||||
|
| | Hot | **3,405** | 3,651 | 10 | 無可用索引 |
|
||||||
|
| | Entity | 0.28 | 0.33 | 8 | `idx_hp_vendor` |
|
||||||
|
| 4. 單筆 listing 讀全部 meta | 原生 | 0.12 | 0.15 | 9 列 | postmeta:post_id |
|
||||||
|
| | Hot / Entity | 0.09 | 0.11–0.14 | 1 列 | 主鍵 |
|
||||||
|
| 5. 列表頁預載 20 筆 meta | 原生 | 0.59 | 0.61 | 181 列 | postmeta:post_id |
|
||||||
|
| | Hot / Entity | 0.21–0.22 | 0.23–0.26 | 20 列 | 主鍵 |
|
||||||
|
| 6. 單一 vendor 讀使用者 meta | 原生 | 0.11 | 0.14 | 6 列 | usermeta:user_id |
|
||||||
|
| | Entity | 0.08 | 0.11 | 1 列 | 主鍵 |
|
||||||
|
| 7. 寫入:更新一筆價格 | 原生 | 0.12 | 0.16 | — | — |
|
||||||
|
| | Entity | 0.10 | 0.13 | — | — |
|
||||||
|
| | dual_write(兩邊都寫) | 0.22 | 0.26 | — | — |
|
||||||
|
|
||||||
|
## 結論
|
||||||
|
|
||||||
|
1. **原生 meta_query 在百萬規模下無法使用。** 任何以 meta 為條件的搜尋都要 14–17 秒,扁平化是必要的,不是錦上添花。
|
||||||
|
2. **扁平表的價值集中在「搜尋與篩選」。** 單筆或批次讀 meta(情境 4–6)三種形式都在 1 ms 內,差距可以忽略,而且實務上還有 object cache。
|
||||||
|
3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。
|
||||||
|
- **PR-028**:post 群組表可存 `post_status`、`post_date` 副本與 `(post_status, post_date)` 索引(`wp tmdo post-status-sync`)。1M 列實測:加欄位 0.05 秒(instant)、建索引 5.3 秒、分批(1,000 列)回填 32 秒,之後與 `posts` 0 筆不一致。欄位 collation 必須跟 `posts.post_status` 相同,否則比對會出現 illegal mix of collations。查詢改寫在 PR-029。
|
||||||
|
- **PR-029**:副本 ready 且無 NULL 時,單一 post type、明確狀態、依日期排序的搜尋改為 INNER JOIN、`post_status IN (…)`、`ORDER BY` 副本 `post_date`、不 GROUP BY(bench 變體 `entity+copy`,`--iterations=5`)。仍帶 `SQL_CALC_FOUND_ROWS` 時:情境 1 p50 670→770 ms、**p95 3,739→773 ms**;情境 2 p50 356→448 ms(變慢,因為算總數仍要掃完所有符合列)。拿掉 `SQL_CALC_FOUND_ROWS` 後同形狀情境 1 只要 **8 ms**(走 `idx_post_status_date`、LIMIT 提早結束)→ 完整效益要搭配 PR-030。
|
||||||
|
- **PR-030**:路由後的搜尋不再帶 `SQL_CALC_FOUND_ROWS`;總數改由同一查詢的 `COUNT(*)` 回答,存在物件快取,key 綁 `last_changed(posts)`(核心存文、Hook Bus 扁平寫入都會推進)。1M 實測:快取命中時整頁 **8 ms**(原 694 / 363 ms);快取失效後第一次為頁面 8 ms + COUNT 608 / 344 ms,與原本相當。任何 post 寫入都讓全部計數失效(WP last_changed 語意),寫入頻繁的站命中率較低。
|
||||||
|
4. **Hot 表的索引規則有漏洞。** `add_covering_indexes()` 只替 decimal、tinyint,以及名稱以 `_time` 結尾的 bigint 建索引,所以 `hp_vendor` 沒有索引,情境 3 要 3.4 秒,比 Entity 慢約 12,000 倍。
|
||||||
|
5. **Entity 表只有單欄索引。** 情境 2 輸給有複合索引的 Hot 表(337 ms 對 277 ms)。
|
||||||
|
- **PR-027 之後**:群組可用 `register_group( ..., array( 'indexes' => ... ) )` 宣告複合索引。在 1M 列的 Entity 表加上 `cidx_verified_price (hp_verified, hp_price)` 花 8.1 秒;重跑情境 2(`--iterations=10`),Entity p50 從 337 ms 降到 **278 ms**(p95 285 ms),Hot 是 269 ms(p95 280 ms),差距在量測誤差內。執行計畫改走 `e:cidx_verified_price`。
|
||||||
|
6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。
|
||||||
|
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
|
||||||
|
|
||||||
|
## 優化候選(待使用者確認後排入 backlog)
|
||||||
|
|
||||||
|
| # | 候選 | 針對 | 預期效果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| O-1 | Entity 表把 `post_status`、`post_date` 一併存進來,並建 `(hp_status, post_date)` 等複合索引,讓搜尋不必 JOIN 回 `posts` 排序 | 情境 1、2 | 寬範圍搜尋改為走索引排序,避免 filesort |
|
||||||
|
| O-2 | Entity 表支援群組層級的複合索引宣告,例如 `(hp_verified, hp_price)`、`(hp_featured, hp_price)` | 情境 2 | 追上或超過 Hot 表 |
|
||||||
|
| O-3 | Hot 表的索引規則補上外鍵型 bigint 欄位(例如 `hp_vendor`) | 情境 3 | 3.4 秒降到 1 ms 以下 |
|
||||||
|
| O-4 | 提供總數快取,或讓路由後的查詢可以不跑 `SQL_CALC_FOUND_ROWS`(MySQL 8 已棄用) | 情境 1、2 | 省去每次搜尋都要算出全部符合列的成本 |
|
||||||
|
| O-5 | 評估 Hot 表與 Entity 表的整併,避免同一份資料存兩份 | 容量、寫入 | 省下約 229 MB 並減少一次寫入 |
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Integration Pattern Decision — 2026-04-25
|
# Integration Pattern Decision — 2026-04-25
|
||||||
|
|
||||||
|
> **2026-09 更新:** 2meet 自家外掛回到「partner 自行註冊」,做法見 `PARTNER-REGISTRATION.md`;對應的 8 個 AddOn 已淘汰。
|
||||||
|
|
||||||
> **v1.0.0 後記(2026-07-31):本文結論已被架構拆分取代。**
|
> **v1.0.0 後記(2026-07-31):本文結論已被架構拆分取代。**
|
||||||
> 當時的兩個選項是「集中在核心」vs「各外掛自帶 bridge」。v1.0.0 走的是第三條路:
|
> 當時的兩個選項是「集中在核心」vs「各外掛自帶 bridge」。v1.0.0 走的是第三條路:
|
||||||
> 每個夥伴外掛對應**一個獨立 AddOn 外掛**(`2meet-data-optimizer-<partner>-addon`,共 11 個),
|
> 每個夥伴外掛對應**一個獨立 AddOn 外掛**(`2meet-data-optimizer-<partner>-addon`,共 11 個),
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Partner 外掛自行註冊指南
|
||||||
|
|
||||||
|
> 適用:2meet 自家外掛(bookings、collab、hub、infocards、mobile-bridge、playlist、quotation、spoke)。
|
||||||
|
> 這 8 個外掛原本各有一個 `2meet-data-optimizer-<slug>-addon`,替它們向核心註冊資料表和欄位。
|
||||||
|
> 這些 AddOn 已淘汰:資料結構的定義應該和資料的擁有者放在一起,所以改由 partner 外掛自己註冊。
|
||||||
|
> 啟用中的 AddOn 會在後台顯示淘汰通知(`TMDO_Deprecated_Addons_Notice`)。
|
||||||
|
>
|
||||||
|
> HivePress、WooCommerce、LatePoint 是第三方外掛,仍然保留各自的 AddOn。
|
||||||
|
|
||||||
|
## 1. 掛上 trait
|
||||||
|
|
||||||
|
核心提供 `TMDO_Anti_EAV_Aware` trait(`includes/trait-tmdo-anti-eav-aware.php`)。trait 由核心載入,所以只有在核心存在時才宣告 bridge 類別,把它放在獨立檔案:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 2meet-bookings.php(partner 主檔)
|
||||||
|
add_action(
|
||||||
|
'plugins_loaded',
|
||||||
|
static function () {
|
||||||
|
if ( ! trait_exists( 'TMDO_Anti_EAV_Aware' ) ) {
|
||||||
|
return; // 核心未啟用:什麼都不做。
|
||||||
|
}
|
||||||
|
require_once __DIR__ . '/includes/class-tmb-tmdo-bridge.php';
|
||||||
|
( new TMB_TMDO_Bridge() )->boot();
|
||||||
|
},
|
||||||
|
3
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
// includes/class-tmb-tmdo-bridge.php
|
||||||
|
final class TMB_TMDO_Bridge {
|
||||||
|
use TMDO_Anti_EAV_Aware;
|
||||||
|
|
||||||
|
protected function plugin_slug(): string {
|
||||||
|
return '2meet-bookings';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void {
|
||||||
|
$this->bind_anti_eav_hooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 覆寫需要的 on_register_*(),見下面兩節。
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**時機**:核心在 `plugins_loaded:4` 觸發 `wpdo_register_*`,並在 `init:1` 再觸發一次。
|
||||||
|
建議掛在 `plugins_loaded:3`,這樣第一次觸發就能收到你的註冊,`init` 之前的讀寫也會被正確路由。
|
||||||
|
掛在 `:5` 之後也能運作(會在 `init:1` 補上),只是 `init` 之前的請求看不到你的欄位。
|
||||||
|
|
||||||
|
**名稱**:請用 `TMDO_Anti_EAV_Aware`。`WPDO_Anti_EAV_Aware` 是舊的 wp-data-optimizer 3.x 的 trait,由 mu-plugin `wpdo-policy-enforcer.php` 從已退役的路徑載入,沒有後面提到的 `post_types` 參數。
|
||||||
|
|
||||||
|
## 2. 註冊自有資料表
|
||||||
|
|
||||||
|
讓 `wp wpdo doctor` / benchmark 認得你的資料表,並做 schema 漂移檢查。以 bookings 為例:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function on_register_custom_tables( TMDO_Custom_Table_Registry $r ): void {
|
||||||
|
$this->register_custom_table(
|
||||||
|
$r,
|
||||||
|
array(
|
||||||
|
'table_name' => '2mb_services', // 不含 $wpdb->prefix
|
||||||
|
'primary_key' => 'id',
|
||||||
|
'post_type_link' => null, // 若每列對應一個 post,填 post_type(playlist 用 'hp_vendor')
|
||||||
|
'expected_columns' => array(
|
||||||
|
'id' => 'BIGINT',
|
||||||
|
'vendor_id' => 'BIGINT',
|
||||||
|
'title' => 'VARCHAR',
|
||||||
|
// …照 CREATE TABLE 逐欄列出
|
||||||
|
),
|
||||||
|
'indexes' => array(
|
||||||
|
'vendor_id' => array( 'vendor_id' ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
collab、playlist、quotation、mobile-bridge 的 AddOn 只做這件事:把各自 `includes/class-tmdo-<slug>.php` 裡 `register_custom_tables()` 的 `$tables` 陣列**原樣**搬進 partner 的 bridge 即可。
|
||||||
|
|
||||||
|
## 3. 註冊 entity 欄位群組(選用)
|
||||||
|
|
||||||
|
只有要把 meta 從 `wp_postmeta` / `wp_usermeta` 等 EAV 表移到扁平表時才需要:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function on_register_entity_fields(): void {
|
||||||
|
$this->register_entity_fields(
|
||||||
|
'post',
|
||||||
|
'tmb_service_core',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'tmb_price', 'type' => 'decimal', 'searchable' => true ),
|
||||||
|
array( 'key' => 'tmb_status', 'type' => 'enum', 'options' => array( 'open', 'closed' ) ),
|
||||||
|
),
|
||||||
|
array( 'post_types' => array( 'tmb_service' ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`post_types`(post 群組建議必填)**:群組只套用在這些 post type。多個群組共用同一個 meta key 時,會依 post 本身的類型決定寫到哪張表;不屬於這些 post type 的 post,這個 key 不受管理(照舊寫原生 meta)。沒有宣告時,共用 key 會被「最後註冊的群組」整個接走。
|
||||||
|
- **定義一旦上線就不要改**:欄位定義的 hash 存在 `wpdo_registry_meta`,改了會對已有資料的扁平表執行 ALTER。要新增欄位時,開新群組。
|
||||||
|
- **群組名稱不可和別人重複**:同名但定義不同時,後註冊的會被忽略,並在 Site Health、後台 Doctor 分頁和 `wp wpdo doctor` 顯示 `[COLLISION]`。
|
||||||
|
|
||||||
|
## 4. 從 AddOn 遷移
|
||||||
|
|
||||||
|
1. 在 partner 的 bridge 裡註冊和 AddOn **完全相同**的資料表與群組(直接複製 AddOn 的定義)。
|
||||||
|
2. 部署 partner 新版,AddOn 先保持啟用。`wp wpdo doctor` 不應出現 `[COLLISION]`;兩邊定義相同時,重複註冊會被靜默忽略。
|
||||||
|
3. 停用 AddOn。
|
||||||
|
4. 再跑一次 `wp wpdo doctor`:資料表仍在清單中,而且不應出現 `[ORPHAN]`。如果出現,表示有群組沒有被 partner 接手;該實體是 aeav_only 時,核心會依 `wpdo_registry_meta` 暫時還原它,讓資料可以讀取,但仍應補上註冊。
|
||||||
|
5. 確認沒有問題後,刪除 AddOn 外掛。
|
||||||
|
|
||||||
|
hub、infocards、spoke 的 partner 外掛本身已經會自己註冊,AddOn 只是重複註冊;停用前照第 2 步確認沒有 `[COLLISION]` 即可。
|
||||||
|
|
||||||
|
## 相關文件
|
||||||
|
|
||||||
|
- `ENTITY_ADAPTER_COOKBOOK.md`:整合深度 Tier 1–3(`TMDO_API` facade、Schema Registry、自訂 adapter)。
|
||||||
|
- `INTEGRATION_PATTERN_DECISION.md`:2026-04 的「partner 自帶 bridge」決策紀錄;本指南回到這個做法。
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# WAF 相容性 — NinjaFirewall
|
||||||
|
|
||||||
|
適用:2meet-data-optimizer v1.0.2+ / NinjaFirewall (WP Edition) 4.9
|
||||||
|
|
||||||
|
**結論:兩者可共存,且不需要開發 AddOn。** 本文記錄實測結果、symlink 多租戶部署的必要設定、WP SaaS 的開站流程與驗證清單,以及開發時必須遵守的三條約束。
|
||||||
|
|
||||||
|
> 只想開新站台的話,直接看 §3;踩到 `Cannot retrieve user options from database (#3)` 看 §2。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 兩種模式的差異
|
||||||
|
|
||||||
|
NinjaFirewall 只有兩種模式(「WP+」是付費版本,不是模式):
|
||||||
|
|
||||||
|
| | WordPress WAF | Full WAF |
|
||||||
|
|---|---|---|
|
||||||
|
| 載入方式 | mu-plugin `0-ninjafirewall.php` | `auto_prepend_file`(`.user.ini` / php.ini) |
|
||||||
|
| 執行時機 | WordPress 載入中 | **WordPress 載入之前** |
|
||||||
|
| 讀設定 | `get_option('nfw_options')` | 自行解析 wp-config.php,**原生 mysqli 直查 `{prefix}options`** |
|
||||||
|
| symlink 安全 | ✅ 站台身分由 WordPress 決定 | ❌ 自行以 `__DIR__` 推導(見 §2) |
|
||||||
|
|
||||||
|
這個差異是後續所有注意事項的根源:**Full WAF 完全繞過 WordPress**,因此 TMDO 的任何 PHP 攔截器對它都不存在,但反過來,TMDO 對 `wp_options` 的**持久性**改動會影響它。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Symlink / 多租戶部署(必讀)
|
||||||
|
|
||||||
|
WP SaaS 常以 symlink 共享 codebase:
|
||||||
|
|
||||||
|
```
|
||||||
|
tenant-a/wp-content/plugins -> /shared/plugins
|
||||||
|
tenant-b/wp-content/plugins -> /shared/plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
Full WAF 在 `lib/firewall.php:78` 這樣推導站台位置:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$nfw_['wp_content'] = dirname(dirname(dirname( __DIR__ )));
|
||||||
|
```
|
||||||
|
|
||||||
|
**PHP 的 `__DIR__` 會解析 symlink 到真實路徑**,於是每個租戶都被判定為「共享目錄所屬的那個站」。接著 `firewall.php:120-121` 讀到錯誤的 wp-config.php、連到錯誤的資料庫,撈不到該站的 `nfw_options`,最後在 `firewall.php:613` 回傳錯誤碼 6:
|
||||||
|
|
||||||
|
```
|
||||||
|
NinjaFirewall fatal error: Cannot retrieve user options from database (#3).
|
||||||
|
Review your installation, your site is not protected.
|
||||||
|
```
|
||||||
|
|
||||||
|
最後那句是字面意義的 —— 此時 WAF 已 `nfw_quit()`,**完全不做任何過濾**。
|
||||||
|
|
||||||
|
### 解法:以 DOCUMENT_ROOT 為錨點
|
||||||
|
|
||||||
|
`.htninja` 的搜尋位置是 `$_SERVER['DOCUMENT_ROOT']`(`firewall.php:47-48`),那是 web server 的 per-site 值,不受 symlink 影響。第二順位是 `dirname(DOCUMENT_ROOT)`,因此**一份檔案即可服務所有租戶**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
// 置於各站 DOCUMENT_ROOT 的共同父目錄,例如 /var/www/Studio/.htninja
|
||||||
|
$nfw_site_root = rtrim( $_SERVER['DOCUMENT_ROOT'] ?? '', '/' );
|
||||||
|
|
||||||
|
if ( '' !== $nfw_site_root && is_file( $nfw_site_root . '/wp-config.php' ) ) {
|
||||||
|
if ( ! defined( 'NFW_LOG_DIR' ) ) {
|
||||||
|
define( 'NFW_LOG_DIR', $nfw_site_root . '/wp-content' ); // firewall.php:82-83
|
||||||
|
}
|
||||||
|
$wp_config = $nfw_site_root . '/wp-config.php'; // firewall.php:120-122
|
||||||
|
}
|
||||||
|
|
||||||
|
unset( $nfw_site_root );
|
||||||
|
```
|
||||||
|
|
||||||
|
覆寫時序:`.htninja` 於 L50 載入 → `NFW_LOG_DIR` 於 L82 生效 → `NFWSESSION_DIR` 跟著 log_dir → `$wp_config` 於 L120 生效。
|
||||||
|
|
||||||
|
**注意事項**
|
||||||
|
|
||||||
|
- **不要在 `.htninja` 使用 `return`** —— 回傳 `'ALLOW'` / `'BLOCK'` 會改變過濾行為(`firewall.php:54-71`)。
|
||||||
|
- `NFW_LOG_DIR` 對 **WP WAF 模式同樣重要**:`firewall.php:78` 在該模式下一樣會誤判,導致所有租戶的 firewall log、`cache/db_hash.N.php`、session 全寫進共享目錄、互相覆蓋。
|
||||||
|
- **每個租戶的 DB 都要有自己的 `nfw_options`**。修好路徑後 WAF 會連到正確的租戶資料庫,但該站若從未啟用過 NinjaFirewall,那一列不存在,照樣 #3。開站流程必須包含啟用步驟。
|
||||||
|
- `nfw_rules` 約 77KB、`autoload=auto`,每個租戶一份,規則更新也要逐站執行。
|
||||||
|
- 若租戶 DB 憑證不在 wp-config.php,可改用 `firewall.php:466-470`:在 `.htninja` 設 `$GLOBALS['nfw_mysqli']` 與 `$GLOBALS['nfw_table_prefix']` 直接提供連線。代價是每 request 多一條 mysqli 連線。
|
||||||
|
- **`.user.ini` 必須讓 php-fpm worker(通常是 www-data)可讀**,否則 `auto_prepend_file` 靜默失效、Full WAF 等同未安裝。`root:root 0640` 是常見的踩雷組合。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. WP SaaS 開站流程
|
||||||
|
|
||||||
|
以 symlink 共享 codebase 的多租戶環境,NinjaFirewall 的每一項設定都分成「平台層做一次」與「每站都要做」兩類。混淆這兩者是最常見的失誤來源。
|
||||||
|
|
||||||
|
### 3.1 平台層(全租戶共用,只做一次)
|
||||||
|
|
||||||
|
**A. 站台錨點 `.htninja`** — 見 §2。放在各站 DOCUMENT_ROOT 的共同父目錄(例如 `/var/www/Studio/.htninja`),內容以 `$_SERVER['DOCUMENT_ROOT']` 動態推導,因此一份即可服務所有租戶,新增租戶時不需修改。
|
||||||
|
|
||||||
|
**B. nginx 規則** — 這是 per-site 設定檔,但內容對所有站相同,建議做成 snippet 讓各站 `include`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# NinjaFirewall 的 log / cache / loader 目錄。
|
||||||
|
# 它自帶的 .htaccess 在 nginx 下完全無效,且 nginx 與 php-fpm 同為 www-data,
|
||||||
|
# 檔案權限無法區分「WAF 寫入」與「對外 serve」,因此只能在這裡封鎖。
|
||||||
|
# ^~ 是必要的:讓前綴匹配優先於 \.php$ 正則,否則 .php 請求會落到 PHP handler。
|
||||||
|
location ^~ /wp-content/nfwlog/ { deny all; }
|
||||||
|
|
||||||
|
# 所有 dotfile:.htaccess / .htninja / .user.ini / .env / .git ...
|
||||||
|
# 放行 .well-known,否則 Certbot 的 ACME challenge 會失敗、憑證無法續期。
|
||||||
|
location ~ /\.(?!well-known) { deny all; }
|
||||||
|
```
|
||||||
|
|
||||||
|
漏掉這段的後果:`nfwlog/` 下的 `readme.txt` 會被公開讀取(等於告訴掃描器這站跑 NinjaFirewall),未來的 `session/sess_*` 檔也不是 `.php`、同樣裸奔。`.php` 檔本身有雙層保護(引擎開頭的 `die('Forbidden')` + 檔內 `<?php exit; ?>`)不會外洩內容,但不該依賴它。
|
||||||
|
|
||||||
|
### 3.2 每個新租戶站台(順序不可顛倒)
|
||||||
|
|
||||||
|
**順序很重要**:先確保錨點與設定就緒,再安裝 Full WAF。順序顛倒會直接撞上 §2 的 `#3` 錯誤。
|
||||||
|
|
||||||
|
**Step 1 — 啟用外掛,建立該站自己的設定**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wp --path=/var/www/sites/<tenant> plugin activate ninjafirewall
|
||||||
|
wp --path=/var/www/sites/<tenant> option list --search='nfw*' --fields=option_name,autoload
|
||||||
|
# 必須看到 nfw_options 與 nfw_rules,否則後續一定 #3
|
||||||
|
```
|
||||||
|
|
||||||
|
`nfw_options` / `nfw_rules` 存在**該租戶自己的資料庫**,不會因為 codebase 共享而自動存在。這是 `#3` 最常見的成因 —— 路徑修對了,但那個站從沒跑過 installer。
|
||||||
|
|
||||||
|
**Step 2 — 確認目錄權限**
|
||||||
|
|
||||||
|
php-fpm worker(通常 www-data)必須能在 `nfwlog/` 建立與寫入檔案:
|
||||||
|
|
||||||
|
| 路徑 | 建議 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| `wp-content/nfwlog/` 及子目錄 | `2775` `wpdev:www-data` | setgid 確保新檔繼承 group;group 需 `w` 否則 WAF 寫不了 log |
|
||||||
|
| `nfwlog/` 內檔案 | `664` | 同上 |
|
||||||
|
| `nfwlog/session/` | 不用管 | 由 WAF 自建,它會用自己的嚴格權限(`0700`) |
|
||||||
|
| `.user.ini`(裝 Full WAF 後才有) | `664` `wpdev:www-data` | www-data 需可讀,否則 auto_prepend 靜默失效 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NFWLOG=/var/www/sites/<tenant>/wp-content/nfwlog
|
||||||
|
sudo find "$NFWLOG" -type d -exec chmod 2775 {} +
|
||||||
|
sudo find "$NFWLOG" -type f -exec chmod 664 {} +
|
||||||
|
```
|
||||||
|
|
||||||
|
若 `nfwlog/` 的 owner 本來就是 www-data(例如全程由後台建立),預設 `0755` 也可運作 —— owner 有寫入權。會出事的是「owner 是別人、group 只有 `r-x`」這種組合。
|
||||||
|
|
||||||
|
**Step 3 — 安裝 Full WAF(從後台)**
|
||||||
|
|
||||||
|
務必從 WordPress 後台操作,不要手工建檔:後台以 www-data 執行,權限天然正確;且官方安裝流程有 sandbox 驗證,會先確認 `auto_prepend_file` 真的生效才寫入設定,避免寫出讓整站 500 的 `.user.ini`。
|
||||||
|
|
||||||
|
裝完立刻檢查 `.user.ini` 權限(見上表)。`root:root 0640` 是常見的踩雷組合 —— PHP 讀不到,Full WAF 等同沒裝,而表面上一切正常。
|
||||||
|
|
||||||
|
**Step 4 — 套用 nginx 規則並 reload**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 開站後驗證(每站必跑)
|
||||||
|
|
||||||
|
四項缺一不可。只做前兩項會漏掉最隱蔽的失效模式。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SITE=https://<tenant>.example.com
|
||||||
|
NFWLOG=/var/www/sites/<tenant>/wp-content/nfwlog
|
||||||
|
|
||||||
|
# (1) 模式判別 —— 回 Forbidden 代表 Full WAF 生效(auto_prepend 已套用到所有 PHP 請求)
|
||||||
|
curl -sk "$SITE/wp-content/plugins/ninjafirewall/lib/i18n.php" | head -c 9; echo
|
||||||
|
|
||||||
|
# (2) 攔截能力 —— 應為 403
|
||||||
|
curl -sk -o /dev/null -w '%{http_code}\n' "$SITE/?t=../../etc/passwd"
|
||||||
|
|
||||||
|
# (3) 記錄能力 —— 最容易被漏掉的一項,log 必須增長
|
||||||
|
before=$(stat -c%s "$NFWLOG/firewall_$(date +%Y-%m).php")
|
||||||
|
curl -sk -o /dev/null "$SITE/?t=../../etc/passwd"
|
||||||
|
after=$(stat -c%s "$NFWLOG/firewall_$(date +%Y-%m).php")
|
||||||
|
[ "$after" -gt "$before" ] && echo "log OK" || echo "log FAIL — 檢查 nfwlog 權限"
|
||||||
|
|
||||||
|
# (4) web 封鎖與功能未損
|
||||||
|
for p in /wp-content/nfwlog/ /wp-content/nfwlog/readme.txt /.user.ini /.htaccess; do
|
||||||
|
printf "%-40s %s\n" "$p" "$(curl -sk -o /dev/null -w '%{http_code}' "$SITE$p")" # 全部應為 403
|
||||||
|
done
|
||||||
|
for p in / /wp-json/ /wp-admin/; do
|
||||||
|
printf "%-40s %s\n" "$p" "$(curl -sk -o /dev/null -w '%{http_code}' "$SITE$p")" # 200 / 200 / 302
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
**第 (3) 項為什麼不能省**:攔截與記錄是兩件事。權限不足時 WAF 照樣回 403,但一筆記錄都寫不進去 —— 你得到一個沒有稽核軌跡的防火牆,事故調查時等於沒有。更嚴重的是同一個權限問題會讓 `session/` 建不出來,而 `wl_admin` 管理員白名單靠 `NFWSESSID` session 承載 `nfw_goodguy` 旗標,於是管理員實際上是被全規則掃描的 —— 這正是「後台操作偶爾莫名 403」的來源。
|
||||||
|
|
||||||
|
### 3.4 排查時的常見誤判
|
||||||
|
|
||||||
|
這幾項在實測中都出現過,全部會誤導判斷:
|
||||||
|
|
||||||
|
| 現象 | 直覺結論 | 實際 |
|
||||||
|
|---|---|---|
|
||||||
|
| 首頁 200、後台沒有錯誤訊息 | WAF 正常 | 完全不能推論。WAF 失效時是 `nfw_quit()` 靜默放行,不擋也不記 |
|
||||||
|
| 攻擊 payload 回 403 | Full WAF 生效 | 可能來自共享 mu-plugin 的 WP WAF。用 §3.3 第 (1) 項區分 |
|
||||||
|
| `nfwlog/` 下的檔案回 403 | nginx 規則生效 | 也可能是 nginx 以 www-data 開不了 root-only 檔案。修好權限後會突然變 200 |
|
||||||
|
| `/.well-known/` 回 403 | dotfile 規則誤擋了 ACME | 多半是目錄存在但 autoindex off。用實檔測:`/.well-known/acme-challenge/<file>` 若回 404(WordPress 頁面)而非 403,代表規則有正確放行 |
|
||||||
|
| `wp-login.php` 回 404 | nginx 改壞了 | 檢查是否啟用 `wps-hide-login` 之類的登入頁隱藏外掛 |
|
||||||
|
| `wp tmdo doctor` 全綠 | WAF 與外掛相容 | WP-CLI 完全豁免 WAF(`firewall.php:18-23`),CLI 結果不能當相容性證據,必須另外走 HTTP 驗證 |
|
||||||
|
|
||||||
|
### 3.5 每租戶獨立的維運負擔
|
||||||
|
|
||||||
|
symlink 共享的是 codebase,**不是設定與狀態**。以下每一項都是 per-tenant:
|
||||||
|
|
||||||
|
- **`nfw_options` / `nfw_rules`** 存在各自的 `wp_options`。`nfw_rules` 約 77 KB 且 `autoload=auto`,N 個租戶就是 N 份。
|
||||||
|
- **規則更新**要逐站執行,沒有集中派送機制。
|
||||||
|
- **log / cache / session** 各自獨立(前提是 §2 的 `NFW_LOG_DIR` 已正確設定,否則全部寫進共享目錄互相覆蓋)。
|
||||||
|
- **停用租戶站台時**,`nfwlog/readme.txt` 明示:解除安裝後要等 5 分鐘再刪除該目錄,否則站台可能崩潰。
|
||||||
|
|
||||||
|
規劃階段就要把這些算進成本 —— 特別是「規則更新要逐站跑」這點,租戶數量上去之後需要自動化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 開發約束(三條)
|
||||||
|
|
||||||
|
### 4.1 不得重導向 WAF 的設定選項
|
||||||
|
|
||||||
|
`TMDO_Options_Manager::register_settings_group()` 以 `pre_update_option_{key}` 回傳 `$old_value`,讓選項不再落地 `wp_options`。而 Full WAF 是用原生 mysqli 直查該表 —— 一旦 `nfw_options` / `nfw_rules` 被重導向,WAF 會讀不到設定而**靜默停止防護:不報錯、不寫 log**。
|
||||||
|
|
||||||
|
v1.0.2 起由 `PROTECTED_OPTIONS` 常數擋下,測試見 `tests/unit/OptionsManagerProtectedTest.php`。新增任何「WordPress 載入前就會以原生 SQL 讀取 `wp_options`」的第三方元件時,必須把它的設定鍵加進該清單。
|
||||||
|
|
||||||
|
**autoload 最佳化不受此限** —— `optimize_autoload()` 只改 `autoload` 欄位、不刪列,而 Full WAF 那句 `SELECT *` 不看 autoload。
|
||||||
|
|
||||||
|
### 4.2 不得把 option 名稱字串放進 GET/POST
|
||||||
|
|
||||||
|
規則 **322**(`lev=3`,CRITICAL):
|
||||||
|
|
||||||
|
```
|
||||||
|
(^|\S['"])nfw_(?:options|rules)\b
|
||||||
|
```
|
||||||
|
|
||||||
|
任何 GET/POST 值含 `nfw_options` 或 `nfw_rules` 字串即 **403**(why: "Attempt to modify NinjaFirewall settings")。
|
||||||
|
|
||||||
|
這對「列出 autoload 清單讓使用者勾選清理」這類 UI 是直接的地雷 —— 而 `nfw_rules` 正好是最大的 autoload 項目之一,必然出現在清單裡。若要做這種介面,用索引或 hash 當作 POST 值,或直接走 CLI。
|
||||||
|
|
||||||
|
### 4.3 不得以 base64 傳送含 SQL 語意的 payload
|
||||||
|
|
||||||
|
`nfw_check_b64()`(`firewall.php:1412-1448`,`post_b64` 選項,預設開)會把每個 POST 值 base64 解碼後再比對。明文的 SQL 規則多半需要「以數字/引號開頭」或「以註解結尾」才命中,**base64 版只要「含有」就 CRITICAL 403** —— 涵蓋 `SELECT...FROM...WHERE`、`INSERT INTO`、`UNION SELECT`、`UPDATE...SET`、以及序列化物件 `O:n:"..."`。
|
||||||
|
|
||||||
|
換言之,**編碼會讓事情變糟,不是變好**。硬編碼白名單只有 `fpd_print_order` 與 `g-recaptcha-response` 兩個欄位名。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 風險矩陣(實測結果)
|
||||||
|
|
||||||
|
於 dev30(**Full WAF**、`wl_admin=1`、`no_restapi=0`、`admin_ajax` 未設)實測。**同一組項目在 WP WAF 模式下結果完全相同** —— 兩種模式都跑過:
|
||||||
|
|
||||||
|
| 項目 | 結果 |
|
||||||
|
|---|---|
|
||||||
|
| `wp tmdo status` / `wp tmdo doctor` | ✅ 不受影響(CLI 豁免) |
|
||||||
|
| `GET /wp-json/wpdo/v1/listings`(**無 cookie**) | ✅ 200,且回的是 JSON 而非 WAF 的 HTML 403 頁 |
|
||||||
|
| `?hp_price_min=100&hp_featured=1` 動態欄位 filter | ✅ 200 |
|
||||||
|
| 首頁 / REST 根 | ✅ 200 |
|
||||||
|
| 後台 `/wp-admin/` | ✅ 302(導向登入) |
|
||||||
|
| firewall log 中的 TMDO 相關攔截 | ✅ **0 筆**(log 內全部攔截皆為刻意送出的測試 payload) |
|
||||||
|
|
||||||
|
最後一項是判斷相容性的核心證據:不是「沒看到錯誤」,而是逐筆檢查 firewall log 的攔截記錄,確認沒有任何一筆來自本外掛的正常操作。
|
||||||
|
|
||||||
|
仍需留意的情境:
|
||||||
|
|
||||||
|
| 風險 | 觸發條件 | 緩解 |
|
||||||
|
|---|---|---|
|
||||||
|
| Full WAF 靜默失效 | WAF 設定選項被重導向 | §4.1(已由 `PROTECTED_OPTIONS` 擋下) |
|
||||||
|
| 規則 322 誤擋 | option 名稱字串進 GET/POST | §4.2 |
|
||||||
|
| migration 中斷 | `run_sync_loop()` 的 110 秒同步 POST(`class-tmdo-migration-orchestrator.php:443` `set_time_limit(120)`)被代理切斷;鎖 `LOCK_TTL_SEC=1800` 才釋放 | 大站用 `force_async=true` 或走 CLI |
|
||||||
|
| REST 回 HTML 而非 JSON | 非 administrator 角色,或以 Application Password / JWT 呼叫(無 `NFWSESSID` cookie)→ 不在 `wl_admin` 白名單。403 頁面是 HTML(`firewall.php:1585-1590`),client 端看到的是「JSON parse error」這種難查的錯 | 程式化呼叫走 CLI;或確認帶 cookie |
|
||||||
|
| admin-ajax 被當 bot | `admin_ajax` 選項開啟時,缺 `HTTP_ACCEPT` / `Accept-Language` / UA 不含 `Mozilla` 的請求回 **404**(`firewall.php:1760-1802`) | curl / server-to-server 呼叫需帶完整 header;預設此選項未開 |
|
||||||
|
|
||||||
|
**WP-CLI 完全豁免**:`firewall.php:18-23` 對 `defined('WP_CLI') && WP_CLI && PHP_SAPI === 'cli'` 直接 `return`。TMDO 全部 63 個 CLI 子命令零風險 —— 這也是 migration、snapshot restore、backfill 等重操作建議走 CLI 的另一個理由。
|
||||||
|
|
||||||
|
**管理員幾乎豁免**:`wl_admin=1` 時,帶 `NFWSESSID` cookie 的 administrator 只跑 3 條規則就 `nfw_quit(20)`(`firewall.php:233-252`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 為什麼不需要 AddOn
|
||||||
|
|
||||||
|
| 理由 | 證據 |
|
||||||
|
|---|---|
|
||||||
|
| 無對外 hook API | 全 codebase 零個 `apply_filters('nfw_*')` / `do_action('nfw_*')` |
|
||||||
|
| 官方相容手段都在部署層 | `.htninja`、`exclude_waf_list` UI、wp-config 常數 —— 沒有一項是 plugin 程式碼掛得上的 |
|
||||||
|
| 常見衝突模式在 TMDO 全不存在 | 無 loopback self-POST、無 `db.php`/`object-cache.php` drop-in、無 wp-config 改動、無 `auto_prepend` 操作、無 `$_FILES` 上傳 |
|
||||||
|
|
||||||
|
NinjaFirewall 處理「合法外掛送 SQL 被擋」的官方做法是**在引擎裡硬編碼白名單**(例:`firewall.php:1434-1439` 的 JetPack 例外),需向 NinTechNet 回報才會納入,第三方無法自行擴充。
|
||||||
|
|
||||||
|
因此本外掛的相容性工作全部落在**核心的三條約束**(§4)與**部署設定**(§2),沒有 AddOn 的著力點。
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# ADR-003:Hot zone 與 Entity 表收斂
|
||||||
|
|
||||||
|
- **狀態**:已接受(2026-09-26,選項 A)
|
||||||
|
- **日期**:2026-09-26
|
||||||
|
- **相關**:`BENCHMARK-SCALE.md`(O-3、O-5)、backlog PR-025〜PR-030
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
post 的高頻欄位有兩套扁平化機制,資料可能存兩份:
|
||||||
|
|
||||||
|
| | Hot zone | Entity bridge |
|
||||||
|
|---|---|---|
|
||||||
|
| 表 | `wp_wpdo_hot_{post_type}`(每個 post type 一張) | `wp_wpdo_post_{group}`(每個群組一張) |
|
||||||
|
| 註冊 | `TMDO_Schema_Registry`(zone = hot) | `TMDO_Entity_Registry::register_group()` |
|
||||||
|
| 寫入 | `TMDO_Sync_Bridge`(zone 模組進入 dual_write 後) | `TMDO_Hook_Bus`(實體 mode ≥ dual_write) |
|
||||||
|
| 查詢改寫 | `TMDO_Query_Router`(`hot_*` 模組 cutover 後) | `TMDO_Post_Query_Router`(post mode ≥ shadow_read) |
|
||||||
|
| 生命週期 | 每個 zone 模組 7 狀態 FSM | 每個實體 4 模式(disabled → aeav_only) |
|
||||||
|
| 涵蓋實體 | 只有 post | post、user、term、comment |
|
||||||
|
| 可完全取代原生 meta | 否(cutover 後原生仍保留) | 是(aeav_only) |
|
||||||
|
| 其他 | 單欄 `indexed`、自訂 `data_type` | post_type 範圍(PR-033)、schema hash、衝突偵測、孤兒還原、回寫原生(PR-017/018/031) |
|
||||||
|
|
||||||
|
Warm(瀏覽數 TTL)、Cold(JSON blob)、Archive(已刪除文章的 meta)是 Entity 沒有的能力,**不在本決策範圍**,保持原狀。
|
||||||
|
|
||||||
|
## 事實
|
||||||
|
|
||||||
|
**效能**(100k vendor / 1M listing,`BENCHMARK-SCALE.md`):
|
||||||
|
|
||||||
|
| 情境 | Hot | Entity |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 價格區間搜尋 p50 | 558 ms | 652 ms |
|
||||||
|
| 價格區間 + verified p50 | **277 ms**(有複合索引) | 337 ms |
|
||||||
|
| vendor 名下 listing p50 | 3,405 ms(無索引) | **0.28 ms** |
|
||||||
|
| 單筆 / 列表預載 | 相同 | 相同 |
|
||||||
|
| 容量 | 229 MB | 379 MB |
|
||||||
|
|
||||||
|
唯一 Hot 領先的是有複合索引的情境;Entity 支援複合索引後(PR-027)即可追平。
|
||||||
|
|
||||||
|
**各站使用現況**(2026-09-26 唯讀查詢):
|
||||||
|
|
||||||
|
| 站 | Hot / Cold 表 | zone 模組 | post entity |
|
||||||
|
|---|---|---|---|
|
||||||
|
| dev20 / dev21 / dev22 | 無 | 全 idle | aeav_only(dev21 約 400 萬列) |
|
||||||
|
| dev23 / 24 / 30 / 31 / 40 | 無 | 全 idle | disabled |
|
||||||
|
| dev34(舊 WPDO 1.3.37) | hot_hp_listing 486、hot_hp_vendor 233 | cutover | disabled |
|
||||||
|
| dev10 DB(舊版) | hot_hp_listing 491、hot_hp_vendor 231 | cutover | aeav_only(約 3,900 列) |
|
||||||
|
|
||||||
|
- **沒有任何 2meet-DO 站在用 Hot zone**;有扁平資料的站全部走 Entity。
|
||||||
|
- 只有兩個舊系統的站有 Hot 資料,而它們在 cutover 仍雙寫 postmeta,Hot 表只是副本(PR-022 已確認以 postmeta 為準)。
|
||||||
|
|
||||||
|
**只由 Hot zone 註冊、Entity 群組沒有涵蓋的 HP 欄位**:listing / request 的 `hp_drafted`;bookings 的 `hp_start_time`、`hp_end_time`、`hp_quantity`;memberships 的 `hp_expire_period`、`hp_primary`、`hp_expired_time`。
|
||||||
|
|
||||||
|
## 選項
|
||||||
|
|
||||||
|
**A. 收斂到 Entity(建議)**
|
||||||
|
post 高頻欄位只用 Entity。Hot zone 凍結:不再新增註冊,程式碼暫留只為讀取舊資料,確認沒有站使用後再移除。
|
||||||
|
|
||||||
|
- 優點:一套路由、一份資料;Entity 能力是 Hot 的超集;現況已經全部在用 Entity,不需要搬資料。
|
||||||
|
- 代價:Hot 獨有的欄位(上面那幾個)要改成新的 Entity 群組;情境 2 要靠 PR-027 的複合索引追平。
|
||||||
|
|
||||||
|
**B. 兩套並存**
|
||||||
|
維持現狀。代價是兩套寫入和查詢路徑都要維護,同一個 key 可能存兩份,O-3 之類的最佳化要做兩次。
|
||||||
|
|
||||||
|
**C. 收斂到 Hot**
|
||||||
|
不可行:Hot 只支援 post,也沒有 aeav_only、範圍宣告、回寫工具。
|
||||||
|
|
||||||
|
## 建議(選項 A)的後續步驟
|
||||||
|
|
||||||
|
1. **PR-026(Hot 表 bigint 索引)標為 `[!]` 不做**:沒有站在用 Hot zone。
|
||||||
|
2. **PR-027(Entity 複合索引)照常做**:這是情境 2 追平 Hot 的方式。
|
||||||
|
3. **新增 PR(HP AddOn)**:把 Hot 獨有的欄位改註冊成新的 Entity 群組(例如 `hp_booking_core`、`hp_membership_core`,以及 listing / request 的補充群組);既有群組的定義不能改。
|
||||||
|
4. **新增 PR(HP AddOn)**:HP adapter 停止註冊 hot 欄位;核心對 zone = hot 的新註冊記錄 deprecated 警告。
|
||||||
|
5. **之後**:dev34(PR-022)和 dev10 DB(PR-023)遷出後,再評估移除 `TMDO_Zone_Hot`、`TMDO_Sync_Bridge` 的 hot 路徑與 `TMDO_Query_Router`。
|
||||||
|
|
||||||
|
## 決定
|
||||||
|
|
||||||
|
**選項 A:收斂到 Entity。**(使用者 2026-09-26 決定)
|
||||||
|
|
||||||
|
Hot zone 凍結,依上面的後續步驟執行;backlog 已同步調整(PR-026 標為 `[!]`,新增 HP AddOn 的兩個 PR)。
|
||||||
@@ -284,7 +284,6 @@ class TMDO_Module_Detector {
|
|||||||
$active = match ( $plugin ) {
|
$active = match ( $plugin ) {
|
||||||
'hivepress' => TMDO_Compatibility::is_hivepress_active(),
|
'hivepress' => TMDO_Compatibility::is_hivepress_active(),
|
||||||
'woocommerce' => TMDO_Compatibility::is_woocommerce_active(),
|
'woocommerce' => TMDO_Compatibility::is_woocommerce_active(),
|
||||||
'hpct' => TMDO_Compatibility::is_hpct_active(),
|
|
||||||
'latepoint' => TMDO_Compatibility::is_latepoint_active(),
|
'latepoint' => TMDO_Compatibility::is_latepoint_active(),
|
||||||
default => false,
|
default => false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,52 +29,10 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
class TMDO_Module_Rules {
|
class TMDO_Module_Rules {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default rules for the 15 known modules. Use `get()` to apply filter.
|
* Default rules for the 8 known modules. Use `get()` to apply filter.
|
||||||
*/
|
*/
|
||||||
private const DEFAULT_RULES = array(
|
private const DEFAULT_RULES = array(
|
||||||
// ─── HPCT modules(HivePress + HPCT)────────────────────────────
|
// ─── AddOn modules ──────────────────────────────────────────────
|
||||||
'reviews' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_review',
|
|
||||||
'min_post_count' => 100,
|
|
||||||
'description' => '若使用 HivePress reviews 且累積評論 ≥ 100 條,啟用後可顯著降低 listing 查詢的 JOIN 成本。',
|
|
||||||
),
|
|
||||||
'messages' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_message_thread',
|
|
||||||
'min_post_count' => 50,
|
|
||||||
'description' => 'HivePress messages 累積對話數 ≥ 50 時,啟用 module 可加速 inbox / unread count 查詢。',
|
|
||||||
),
|
|
||||||
'favorites' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_favorite',
|
|
||||||
'min_post_count' => 200,
|
|
||||||
'description' => '使用者 favorite 累積 ≥ 200 條時,啟用 module 把 favorite meta 從 wp_postmeta 搬出。',
|
|
||||||
),
|
|
||||||
'memberships' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_membership',
|
|
||||||
'min_post_count' => 50,
|
|
||||||
'description' => 'HivePress memberships 啟用且 ≥ 50 條訂閱時,membership 過期檢查會更快。',
|
|
||||||
),
|
|
||||||
'statistics' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_listing',
|
|
||||||
'min_post_count' => 500,
|
|
||||||
'description' => 'Listing ≥ 500 條時,view_count / favorite_count 等高頻統計搬到 stats module 可大幅減少 wp_postmeta 寫入。',
|
|
||||||
),
|
|
||||||
'requests' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_request',
|
|
||||||
'min_post_count' => 50,
|
|
||||||
'description' => 'HivePress requests / quotes 累積 ≥ 50 條時建議啟用。',
|
|
||||||
),
|
|
||||||
'listing_meta' => array(
|
|
||||||
'compat_required' => array( 'hivepress' ),
|
|
||||||
'post_type_required' => 'hp_listing',
|
|
||||||
'min_post_count' => 100,
|
|
||||||
'description' => '所有 listing meta 集中管理;listing ≥ 100 時可省下大量 postmeta JOIN。',
|
|
||||||
),
|
|
||||||
'wc_orders' => array(
|
'wc_orders' => array(
|
||||||
'compat_required' => array( 'woocommerce' ),
|
'compat_required' => array( 'woocommerce' ),
|
||||||
'post_type_required' => 'shop_order',
|
'post_type_required' => 'shop_order',
|
||||||
|
|||||||
@@ -37,57 +37,23 @@ class TMDO_Compatibility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self::$detected = array(
|
self::$detected = array(
|
||||||
'hpct_active' => self::is_hpct_active(),
|
'hivepress' => self::is_hivepress_active(),
|
||||||
'hpct_imported' => self::is_hpct_imported(),
|
'woocommerce' => self::is_woocommerce_active(),
|
||||||
'hivepress' => self::is_hivepress_active(),
|
|
||||||
'woocommerce' => self::is_woocommerce_active(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return self::$detected;
|
return self::$detected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Can WPDO register its HPCT-inherited module interceptors?
|
|
||||||
*
|
|
||||||
* True when:
|
|
||||||
* - hp-custom-tables is NOT active, OR
|
|
||||||
* - hp-custom-tables IS active but already imported into WPDO.
|
|
||||||
*/
|
|
||||||
public static function can_register_hpct_hooks(): bool {
|
|
||||||
$compat = self::check();
|
|
||||||
|
|
||||||
// HPCT not active → WPDO can freely register.
|
|
||||||
if ( ! $compat['hpct_active'] ) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HPCT is active but already imported → WPDO takes over.
|
|
||||||
if ( $compat['hpct_imported'] ) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HPCT is active and NOT imported → defer to HPCT.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can WPDO register zone-specific interceptors (hot/warm/cold/archive)?
|
* Can WPDO register zone-specific interceptors (hot/warm/cold/archive)?
|
||||||
*
|
*
|
||||||
* Zone interceptors are always safe to register — they operate on
|
* Zone interceptors are always safe to register — they operate on
|
||||||
* WPDO's own tables and do not conflict with HPCT.
|
* WPDO's own tables.
|
||||||
*/
|
*/
|
||||||
public static function can_register_zone_hooks(): bool {
|
public static function can_register_zone_hooks(): bool {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Should WPDO show an admin notice about HPCT import?
|
|
||||||
*/
|
|
||||||
public static function should_show_hpct_notice(): bool {
|
|
||||||
$compat = self::check();
|
|
||||||
return $compat['hpct_active'] && ! $compat['hpct_imported'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is HivePress core active?
|
* Is HivePress core active?
|
||||||
*/
|
*/
|
||||||
@@ -102,6 +68,26 @@ class TMDO_Compatibility {
|
|||||||
return class_exists( 'WooCommerce' );
|
return class_exists( 'WooCommerce' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integrations that are active while their AddOn is not.
|
||||||
|
*
|
||||||
|
* Core does not know these plugins; their fields are only optimised by the
|
||||||
|
* matching AddOn. The AddOn is detected by its bootstrap class, which its
|
||||||
|
* main file declares as soon as the plugin loads.
|
||||||
|
*
|
||||||
|
* @return array<string, string> Plugin name → AddOn plugin slug.
|
||||||
|
*/
|
||||||
|
public static function missing_integration_addons(): array {
|
||||||
|
$missing = array();
|
||||||
|
if ( self::is_hivepress_active() && ! class_exists( 'TMDO_HP_Bootstrap' ) ) {
|
||||||
|
$missing['HivePress'] = '2meet-data-optimizer-hivepress-addon';
|
||||||
|
}
|
||||||
|
if ( self::is_woocommerce_active() && ! class_exists( 'TMDO_Woocommerce_Bootstrap' ) ) {
|
||||||
|
$missing['WooCommerce'] = '2meet-data-optimizer-woocommerce-addon';
|
||||||
|
}
|
||||||
|
return $missing;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is LatePoint plugin active? (v2.5.0 M16 polish — for module detector.)
|
* Is LatePoint plugin active? (v2.5.0 M16 polish — for module detector.)
|
||||||
*
|
*
|
||||||
@@ -113,23 +99,4 @@ class TMDO_Compatibility {
|
|||||||
|| defined( 'LATEPOINT_VERSION' )
|
|| defined( 'LATEPOINT_VERSION' )
|
||||||
|| function_exists( 'latepoint_get_settings' );
|
|| function_exists( 'latepoint_get_settings' );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Detection Methods ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is HP Custom Tables plugin active (loaded)?
|
|
||||||
*
|
|
||||||
* Checks for the HPCT_Loader class, which is always loaded
|
|
||||||
* when hp-custom-tables is active.
|
|
||||||
*/
|
|
||||||
public static function is_hpct_active(): bool {
|
|
||||||
return class_exists( 'HPCT_Loader' ) || defined( 'HPCT_VERSION' );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Has HPCT been imported into WPDO?
|
|
||||||
*/
|
|
||||||
private static function is_hpct_imported(): bool {
|
|
||||||
return (bool) get_option( 'wpdo_hpct_imported', false );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-287
@@ -31,31 +31,10 @@ class TMDO_Core {
|
|||||||
// Fire the schema registry hook so integrations can register their fields.
|
// Fire the schema registry hook so integrations can register their fields.
|
||||||
$registry = TMDO_Schema_Registry::instance();
|
$registry = TMDO_Schema_Registry::instance();
|
||||||
|
|
||||||
// v2.0.x: Pre-register the 7 partner plugin integrations BEFORE firing the
|
// Core-owned field providers register before `wpdo_register_fields` fires.
|
||||||
// `wpdo_register_fields` and `wpdo_register_custom_tables` hooks. Each
|
// AddOns wire their own integrations from their bootstraps
|
||||||
// `register()` is a thin `add_action()` wrapper guarded by a partner-plugin
|
// (plugins_loaded:3 or :6), so core does not probe for them here.
|
||||||
// `class_exists()` probe (TMDO_Infocards / Bookings / Quotation / Events /
|
foreach ( array( 'TMDO_Member_Fields', 'TMDO_Post_Fields' ) as $cls ) {
|
||||||
// Mobile_Bridge / Collab / Playlist). Without this, partner add_actions
|
|
||||||
// arrive too late and the do_action below misses them — observed via
|
|
||||||
// 2meet-infocards 9 hp_vendor meta keys never landing in Schema Registry.
|
|
||||||
// register() is side-effect-free aside from add_action(), so pre-registering
|
|
||||||
// is provably safe (no hidden dependency on later-state global init).
|
|
||||||
// v2.1.3 R1 (B step): TMDO_Events removed — 2meet-events now self-registers
|
|
||||||
// via its own bridge class. Other partners still pre-registered here pending
|
|
||||||
// their own decentralized migrations (R1 continuation in v2.2.x).
|
|
||||||
$partner_integrations = array(
|
|
||||||
'TMDO_Infocards',
|
|
||||||
'TMDO_Bookings',
|
|
||||||
'TMDO_Quotation',
|
|
||||||
'TMDO_Mobile_Bridge',
|
|
||||||
'TMDO_Collab',
|
|
||||||
'TMDO_Playlist',
|
|
||||||
'TMDO_WooCommerce',
|
|
||||||
'TMDO_Member_Fields',
|
|
||||||
'TMDO_Post_Fields',
|
|
||||||
'TMDO_Hivepress_Term_Comment_Fields',
|
|
||||||
);
|
|
||||||
foreach ( $partner_integrations as $cls ) {
|
|
||||||
if ( class_exists( $cls ) ) {
|
if ( class_exists( $cls ) ) {
|
||||||
call_user_func( array( $cls, 'register' ) );
|
call_user_func( array( $cls, 'register' ) );
|
||||||
}
|
}
|
||||||
@@ -74,7 +53,6 @@ class TMDO_Core {
|
|||||||
// AFTER plugins_loaded:4 (e.g., partners loaded by HP modules at plugins_loaded:5+,
|
// AFTER plugins_loaded:4 (e.g., partners loaded by HP modules at plugins_loaded:5+,
|
||||||
// or lazy-loaded modules booted on init:0). Schema_Registry::register() has
|
// or lazy-loaded modules booted on init:0). Schema_Registry::register() has
|
||||||
// internal dedup, so re-firing is idempotent — same partner calls won't double-register.
|
// internal dedup, so re-firing is idempotent — same partner calls won't double-register.
|
||||||
// This deprecates the need for the TMDO_Core hardcoded partner pre-registration list above.
|
|
||||||
//
|
//
|
||||||
// v2.1.6: also re-fire `wpdo_register_entity_fields` so partner plugins booting on
|
// v2.1.6: also re-fire `wpdo_register_entity_fields` so partner plugins booting on
|
||||||
// plugins_loaded:20 (e.g. 2meet-spoke-sso) can register entity-based user/term/comment
|
// plugins_loaded:20 (e.g. 2meet-spoke-sso) can register entity-based user/term/comment
|
||||||
@@ -93,6 +71,12 @@ class TMDO_Core {
|
|||||||
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
||||||
TMDO_Custom_Table_Registry::instance()->fire_registration();
|
TMDO_Custom_Table_Registry::instance()->fire_registration();
|
||||||
}
|
}
|
||||||
|
// Every provider has registered by now: re-register groups whose flat
|
||||||
|
// table still holds data but whose plugin is gone, so aeav_only
|
||||||
|
// values keep reading back (routing only — never DDL).
|
||||||
|
if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||||
|
TMDO_Schema_Manager::restore_orphaned_groups();
|
||||||
|
}
|
||||||
if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) {
|
if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||||
$pending = TMDO_Entity_Registry::get_pending_schemas();
|
$pending = TMDO_Entity_Registry::get_pending_schemas();
|
||||||
if ( ! empty( $pending ) ) {
|
if ( ! empty( $pending ) ) {
|
||||||
@@ -111,17 +95,6 @@ class TMDO_Core {
|
|||||||
// Cache Layer hooks (Zone C prefetch + save_post warming).
|
// Cache Layer hooks (Zone C prefetch + save_post warming).
|
||||||
TMDO_Cache_Layer::register_hooks();
|
TMDO_Cache_Layer::register_hooks();
|
||||||
|
|
||||||
// Register HPCT-inherited module interceptors if allowed.
|
|
||||||
if ( TMDO_Compatibility::can_register_hpct_hooks() ) {
|
|
||||||
$this->register_hpct_interceptors();
|
|
||||||
}
|
|
||||||
|
|
||||||
// v2.1.2 fix: WC commission interceptor is independent of HPCT — writes
|
|
||||||
// to its own wp_wpdo_wc_commissions table. Register whenever WC is active.
|
|
||||||
if ( class_exists( 'TMDO_WooCommerce' ) && TMDO_WooCommerce::is_active() && class_exists( 'TMDO_WC_Orders_Interceptor' ) ) {
|
|
||||||
( new TMDO_WC_Orders_Interceptor() )->register_hooks();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zone interceptors are always safe to register.
|
// Zone interceptors are always safe to register.
|
||||||
if ( TMDO_Compatibility::can_register_zone_hooks() ) {
|
if ( TMDO_Compatibility::can_register_zone_hooks() ) {
|
||||||
$this->register_zone_hooks();
|
$this->register_zone_hooks();
|
||||||
@@ -136,6 +109,9 @@ class TMDO_Core {
|
|||||||
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
|
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
|
||||||
TMDO_Hook_Bus_Bridge::maybe_init_hook_bus();
|
TMDO_Hook_Bus_Bridge::maybe_init_hook_bus();
|
||||||
|
|
||||||
|
// Keep post_status / post_date copies on post entity tables current.
|
||||||
|
TMDO_Post_Status_Sync::register();
|
||||||
|
|
||||||
// Register audit log listeners for wpdo_after_write / wpdo_after_delete.
|
// Register audit log listeners for wpdo_after_write / wpdo_after_delete.
|
||||||
if ( class_exists( 'TMDO_Audit_Logger' ) ) {
|
if ( class_exists( 'TMDO_Audit_Logger' ) ) {
|
||||||
TMDO_Audit_Logger::init();
|
TMDO_Audit_Logger::init();
|
||||||
@@ -149,18 +125,9 @@ class TMDO_Core {
|
|||||||
TMDO_Conflict_Monitor::register_hooks();
|
TMDO_Conflict_Monitor::register_hooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2.0.x: partner plugin integrations have been pre-registered earlier
|
|
||||||
// (see top of run() before the wpdo_register_fields do_action). Removing
|
|
||||||
// the duplicate late-binding block — see commit log for time-ordering bug.
|
|
||||||
|
|
||||||
// Schedule cron events.
|
// Schedule cron events.
|
||||||
$this->schedule_cron();
|
$this->schedule_cron();
|
||||||
|
|
||||||
// Admin notice for HPCT import.
|
|
||||||
if ( is_admin() && TMDO_Compatibility::should_show_hpct_notice() ) {
|
|
||||||
add_action( 'admin_notices', array( $this, 'render_hpct_notice' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
// v2.6.6: async entity backfill cron handler.
|
// v2.6.6: async entity backfill cron handler.
|
||||||
add_action( 'wpdo_entity_backfill_batch', array( $this, 'run_entity_backfill_batch' ), 10, 2 );
|
add_action( 'wpdo_entity_backfill_batch', array( $this, 'run_entity_backfill_batch' ), 10, 2 );
|
||||||
|
|
||||||
@@ -182,14 +149,6 @@ class TMDO_Core {
|
|||||||
add_action( TMDO_Comment_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_comment_stress_test_batch' ) );
|
add_action( TMDO_Comment_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_comment_stress_test_batch' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2.11.5: HivePress per-post transient cache → wp_options reroute.
|
|
||||||
// Filters registered on `init` so HivePress's own bootstrap (plugins_loaded:5)
|
|
||||||
// runs first and our filter chain catches the actual cache writes happening
|
|
||||||
// during save_post and term-cache rebuilds.
|
|
||||||
if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
|
|
||||||
add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 );
|
|
||||||
}
|
|
||||||
|
|
||||||
// v2.12.1: Term + Comment garbage write-time filter
|
// v2.12.1: Term + Comment garbage write-time filter
|
||||||
// (silent-drop _wxr_import_* / _2meet_demo_* / orphan post-meta keys).
|
// (silent-drop _wxr_import_* / _2meet_demo_* / orphan post-meta keys).
|
||||||
if ( class_exists( 'TMDO_Term_Comment_Garbage_Filter' ) ) {
|
if ( class_exists( 'TMDO_Term_Comment_Garbage_Filter' ) ) {
|
||||||
@@ -282,218 +241,6 @@ class TMDO_Core {
|
|||||||
do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
|
do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Register default HivePress field mappings.
|
|
||||||
*
|
|
||||||
* These are the known HivePress meta_keys and their zone classifications.
|
|
||||||
*
|
|
||||||
* @param TMDO_Schema_Registry $registry The schema registry instance.
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function register_hivepress_defaults( TMDO_Schema_Registry $registry ): void {
|
|
||||||
// Zone A (Hot) — Listing search/filter fields.
|
|
||||||
$registry->register_many(
|
|
||||||
'hivepress',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_price',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_price',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_featured',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_featured',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_verified',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_verified',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_expired_time',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_expired_time',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_featured_time',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_featured_time',
|
|
||||||
),
|
|
||||||
// Vendor hot fields.
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_vendor',
|
|
||||||
'meta_key' => 'hp_verified',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_verified',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_vendor',
|
|
||||||
'meta_key' => 'hp_hourly_rate',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_hourly_rate',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_vendor',
|
|
||||||
'meta_key' => 'hp_rating_count',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'int(11) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_rating_count',
|
|
||||||
'indexed' => false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Zone C (Cold) — Profile/display fields.
|
|
||||||
$registry->register_many(
|
|
||||||
'hivepress',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_vendor',
|
|
||||||
'meta_key' => 'hp_description',
|
|
||||||
'zone' => 'cold',
|
|
||||||
'cache_group' => 'wpdo_cold_hp_vendor',
|
|
||||||
'cache_ttl' => HOUR_IN_SECONDS,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_description',
|
|
||||||
'zone' => 'cold',
|
|
||||||
'cache_group' => 'wpdo_cold_hp_listing',
|
|
||||||
'cache_ttl' => HOUR_IN_SECONDS,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── HivePress extension fields (filled the 47% → 94% coverage gap) ──
|
|
||||||
// Reviews extension (hp_review post type).
|
|
||||||
$registry->register_many(
|
|
||||||
'hivepress-reviews',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_review',
|
|
||||||
'meta_key' => 'hp_rating',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_rating',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_vendor',
|
|
||||||
'meta_key' => 'hp_rating',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'decimal(3,2) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_rating',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_review',
|
|
||||||
'meta_key' => 'hp_text',
|
|
||||||
'zone' => 'cold',
|
|
||||||
'cache_group' => 'wpdo_cold_hp_review',
|
|
||||||
'cache_ttl' => HOUR_IN_SECONDS,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bookings extension (hp_booking post type).
|
|
||||||
$registry->register_many(
|
|
||||||
'hivepress-bookings',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_booking',
|
|
||||||
'meta_key' => 'hp_start_time',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'bigint(20) 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) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_end_time',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_booking',
|
|
||||||
'meta_key' => 'hp_status',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => "varchar(20) NOT NULL DEFAULT ''",
|
|
||||||
'column' => 'hp_status',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_listing',
|
|
||||||
'meta_key' => 'hp_booking_enabled',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_booking_enabled',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Marketplace / Requests extension.
|
|
||||||
$registry->register_many(
|
|
||||||
'hivepress-marketplace',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_request',
|
|
||||||
'meta_key' => 'hp_request_category',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_request_category',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_offer',
|
|
||||||
'meta_key' => 'hp_amount',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_amount',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'post_type' => 'hp_offer',
|
|
||||||
'meta_key' => 'hp_request',
|
|
||||||
'zone' => 'hot',
|
|
||||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
||||||
'column' => 'hp_request',
|
|
||||||
'indexed' => true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register HPCT-inherited module interceptors and query interceptors.
|
|
||||||
*/
|
|
||||||
private function register_hpct_interceptors(): void {
|
|
||||||
// 7 HPCT-era meta interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
|
|
||||||
// 1 LatePoint interceptor 已搬到 2meet-data-optimizer-latepoint-addon。
|
|
||||||
// 5 query interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
|
|
||||||
// 此方法保留為空殼以保持 ABI 相容;AddOn 各自於自身 bootstrap 內 register_hooks。
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register zone-specific hooks.
|
* Register zone-specific hooks.
|
||||||
*/
|
*/
|
||||||
@@ -791,27 +538,6 @@ class TMDO_Core {
|
|||||||
TMDO_Zone_Archive::sweep();
|
TMDO_Zone_Archive::sweep();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render admin notice when hp-custom-tables is detected but not imported.
|
|
||||||
*/
|
|
||||||
public function render_hpct_notice(): void {
|
|
||||||
$import_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=hpct-import' );
|
|
||||||
?>
|
|
||||||
<div class="notice notice-warning is-dismissible">
|
|
||||||
<p>
|
|
||||||
<strong>WP Data Optimizer:</strong>
|
|
||||||
<?php
|
|
||||||
printf(
|
|
||||||
/* translators: %s: URL to import page */
|
|
||||||
esc_html__( 'HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。', '2meet-data-optimizer' ),
|
|
||||||
'<a href="' . esc_url( $import_url ) . '">' . esc_html__( 'HPCT 匯入頁面', '2meet-data-optimizer' ) . '</a>'
|
|
||||||
);
|
|
||||||
?>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WP Cron handler: process one backfill batch for an entity/group pair.
|
* WP Cron handler: process one backfill batch for an entity/group pair.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -27,17 +27,9 @@ class TMDO_Feature_Flags {
|
|||||||
private const OPTION_KEY = 'wpdo_features';
|
private const OPTION_KEY = 'wpdo_features';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All known HPCT-inherited modules.
|
* Modules driven by AddOn interceptors (WooCommerce commissions, LatePoint).
|
||||||
* These modules operate on existing hpct_* tables.
|
|
||||||
*/
|
*/
|
||||||
public const HPCT_MODULES = array(
|
public const ADDON_MODULES = array(
|
||||||
'reviews',
|
|
||||||
'messages',
|
|
||||||
'favorites',
|
|
||||||
'memberships',
|
|
||||||
'statistics',
|
|
||||||
'requests',
|
|
||||||
'listing_meta',
|
|
||||||
'wc_orders',
|
'wc_orders',
|
||||||
'latepoint',
|
'latepoint',
|
||||||
);
|
);
|
||||||
@@ -300,7 +292,7 @@ class TMDO_Feature_Flags {
|
|||||||
$saved = array();
|
$saved = array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$all_modules = array_merge( self::HPCT_MODULES, self::ZONE_MODULES );
|
$all_modules = array_merge( self::ADDON_MODULES, self::ZONE_MODULES );
|
||||||
$defaults = array_fill_keys( $all_modules, 'idle' );
|
$defaults = array_fill_keys( $all_modules, 'idle' );
|
||||||
|
|
||||||
self::$cache = array_merge( $defaults, $saved );
|
self::$cache = array_merge( $defaults, $saved );
|
||||||
@@ -308,13 +300,13 @@ class TMDO_Feature_Flags {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return only HPCT-inherited module states.
|
* Return only AddOn module states.
|
||||||
*
|
*
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
public static function hpct_modules(): array {
|
public static function addon_modules(): array {
|
||||||
$all = self::all();
|
$all = self::all();
|
||||||
return array_intersect_key( $all, array_flip( self::HPCT_MODULES ) );
|
return array_intersect_key( $all, array_flip( self::ADDON_MODULES ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -326,23 +318,4 @@ class TMDO_Feature_Flags {
|
|||||||
$all = self::all();
|
$all = self::all();
|
||||||
return array_intersect_key( $all, array_flip( self::ZONE_MODULES ) );
|
return array_intersect_key( $all, array_flip( self::ZONE_MODULES ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Map an HPCT feature flag status to a WPDO state.
|
|
||||||
*
|
|
||||||
* Used during HPCT import to translate HPCT's 4-state model
|
|
||||||
* to WPDO's 7-state model.
|
|
||||||
*
|
|
||||||
* @param string $hpct_status HPCT status (disabled/migrating/verified/enabled).
|
|
||||||
* @return string WPDO state.
|
|
||||||
*/
|
|
||||||
public static function map_hpct_status( string $hpct_status ): string {
|
|
||||||
return match ( $hpct_status ) {
|
|
||||||
'disabled' => 'idle',
|
|
||||||
'migrating' => 'backfill',
|
|
||||||
'verified' => 'cutover',
|
|
||||||
'enabled' => 'complete',
|
|
||||||
default => 'idle',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ final class TMDO_Hook_Bus_Bridge {
|
|||||||
if ( null !== self::$cache ) {
|
if ( null !== self::$cache ) {
|
||||||
return self::$cache;
|
return self::$cache;
|
||||||
}
|
}
|
||||||
$value = get_option( self::OPTION, '1' ); // Default ON as of v2.5.4.
|
$value = get_option( self::OPTION, '0' );
|
||||||
self::$cache = ( '1' === (string) $value || true === $value );
|
self::$cache = ( '1' === (string) $value || true === $value );
|
||||||
return self::$cache;
|
return self::$cache;
|
||||||
}
|
}
|
||||||
@@ -98,13 +98,6 @@ final class TMDO_Hook_Bus_Bridge {
|
|||||||
* @var string[]
|
* @var string[]
|
||||||
*/
|
*/
|
||||||
private const COEXIST_WHITELIST = array(
|
private const COEXIST_WHITELIST = array(
|
||||||
'TMDO_Reviews_Interceptor',
|
|
||||||
'TMDO_Messages_Interceptor',
|
|
||||||
'TMDO_Favorites_Interceptor',
|
|
||||||
'TMDO_Memberships_Interceptor',
|
|
||||||
'TMDO_Statistics_Interceptor',
|
|
||||||
'TMDO_Requests_Interceptor',
|
|
||||||
'TMDO_Listing_Meta_Interceptor',
|
|
||||||
'TMDO_WC_Orders_Interceptor',
|
'TMDO_WC_Orders_Interceptor',
|
||||||
'TMDO_LatePoint_Interceptor',
|
'TMDO_LatePoint_Interceptor',
|
||||||
'TMDO_Sync_Bridge',
|
'TMDO_Sync_Bridge',
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ class TMDO_Installer {
|
|||||||
*/
|
*/
|
||||||
private const SCHEMA_VERSION = '2.1.0';
|
private const SCHEMA_VERSION = '2.1.0';
|
||||||
|
|
||||||
|
/** Set once maybe_preserve_misc_bucket_default() has run for this site. */
|
||||||
|
public const MISC_BUCKET_DEFAULT_FLAG = 'wpdo_misc_bucket_default_v2';
|
||||||
|
|
||||||
/** Allowed SQL base types for hot-zone column definitions. */
|
/** Allowed SQL base types for hot-zone column definitions. */
|
||||||
private const ALLOWED_COL_BASE_TYPES = array(
|
private const ALLOWED_COL_BASE_TYPES = array(
|
||||||
'bigint',
|
'bigint',
|
||||||
@@ -221,47 +224,12 @@ class TMDO_Installer {
|
|||||||
add_option( 'wpdo_features', array(), '', 'no' );
|
add_option( 'wpdo_features', array(), '', 'no' );
|
||||||
}
|
}
|
||||||
|
|
||||||
// v2.5.4: one-time migration — enable Hook Bus + activate user/term/comment bridge.
|
|
||||||
self::maybe_autoactivate_entity_bridge();
|
|
||||||
|
|
||||||
// v2.15.0: one-time crypto migration — re-encrypt v1 CBC ciphertext as
|
// v2.15.0: one-time crypto migration — re-encrypt v1 CBC ciphertext as
|
||||||
// v2 GCM. Idempotent best-effort: skipped if no v1 ciphertext present
|
// v2 GCM. Idempotent best-effort: skipped if no v1 ciphertext present
|
||||||
// or if the migration flag is already set.
|
// or if the migration flag is already set.
|
||||||
self::maybe_migrate_crypto_v1_to_v2();
|
self::maybe_migrate_crypto_v1_to_v2();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One-time migration: enable Hook Bus and set user/term/comment to dual_write.
|
|
||||||
*
|
|
||||||
* Runs once per site (guarded by wpdo_entity_bridge_autoactivated_v2 flag).
|
|
||||||
* Safe: dual_write still writes native EAV — zero data-loss risk.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private static function maybe_autoactivate_entity_bridge(): void {
|
|
||||||
if ( get_option( 'wpdo_entity_bridge_autoactivated_v2' ) ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable Hook Bus.
|
|
||||||
update_option( 'wpdo_hook_bus_enabled', '1', false );
|
|
||||||
|
|
||||||
// Upgrade user/term/comment from disabled → dual_write.
|
|
||||||
$modes = get_option( 'wpdo_bridge_modes', array() );
|
|
||||||
if ( ! is_array( $modes ) ) {
|
|
||||||
$modes = array();
|
|
||||||
}
|
|
||||||
foreach ( array( 'user', 'term', 'comment' ) as $type ) {
|
|
||||||
if ( ! isset( $modes[ $type ] ) || 'disabled' === $modes[ $type ] ) {
|
|
||||||
$modes[ $type ] = 'dual_write';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// post entity is intentionally left alone — it uses the legacy Feature_Flags FSM.
|
|
||||||
update_option( 'wpdo_bridge_modes', $modes, false );
|
|
||||||
|
|
||||||
update_option( 'wpdo_entity_bridge_autoactivated_v2', '1', false );
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time best-effort crypto migration v1 (CBC) → v2 (GCM) (v2.15.0).
|
* One-time best-effort crypto migration v1 (CBC) → v2 (GCM) (v2.15.0).
|
||||||
*
|
*
|
||||||
@@ -299,6 +267,128 @@ class TMDO_Installer {
|
|||||||
if ( get_option( 'wpdo_db_version' ) !== self::SCHEMA_VERSION ) {
|
if ( get_option( 'wpdo_db_version' ) !== self::SCHEMA_VERSION ) {
|
||||||
self::install();
|
self::install();
|
||||||
}
|
}
|
||||||
|
self::maybe_preserve_misc_bucket_default();
|
||||||
|
self::maybe_init_post_scoped_routing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time default for post_type-scoped entity routing.
|
||||||
|
*
|
||||||
|
* Autoloaded because TMDO_Entity_Registry::get_field() reads it per lookup.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function maybe_init_post_scoped_routing(): void {
|
||||||
|
global $wpdb;
|
||||||
|
if ( false !== get_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, false ) || ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$has_rows = false;
|
||||||
|
$tables = (array) $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%' ) );
|
||||||
|
foreach ( $tables as $table ) {
|
||||||
|
if ( $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from SHOW TABLES.
|
||||||
|
$has_rows = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, self::post_scoped_routing_default( TMDO_Mode_Manager::get( 'post' ), $has_rows ), '', true );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether post_type-scoped routing can start on right away.
|
||||||
|
*
|
||||||
|
* Existing post flat rows were written with legacy routing (a key shared by
|
||||||
|
* several groups went to the last one registered), so they may sit in
|
||||||
|
* another post type's table. Scoped lookups stay off there until the rows
|
||||||
|
* are reconciled; with the post bridge disabled nothing reads them.
|
||||||
|
*
|
||||||
|
* @param string $post_mode Current post entity bridge mode.
|
||||||
|
* @param bool $has_post_flat_rows Whether any wpdo_post_* table has rows.
|
||||||
|
* @return string '1' to enable, '0' to wait for reconcile.
|
||||||
|
*/
|
||||||
|
public static function post_scoped_routing_default( string $post_mode, bool $has_post_flat_rows ): string {
|
||||||
|
return ( 'disabled' === $post_mode || ! $has_post_flat_rows ) ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration for the misc bucket default flip (on → off).
|
||||||
|
*
|
||||||
|
* The bucket used to default to enabled; its rows are only readable while it
|
||||||
|
* is on. Sites that already hold misc rows and never set the toggle get '1'
|
||||||
|
* persisted so turning the default off cannot hide their data. Runs from
|
||||||
|
* maybe_upgrade() rather than install() because install() only runs when
|
||||||
|
* SCHEMA_VERSION changes; the flag is autoloaded since it is read per request.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function maybe_preserve_misc_bucket_default(): void {
|
||||||
|
if ( get_option( self::MISC_BUCKET_DEFAULT_FLAG ) || ! class_exists( 'TMDO_Term_Comment_Misc_Bucket' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$unset = false === get_option( TMDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
|
||||||
|
$has_rows = TMDO_Term_Comment_Misc_Bucket::count_rows( 'term' ) > 0
|
||||||
|
|| TMDO_Term_Comment_Misc_Bucket::count_rows( 'comment' ) > 0;
|
||||||
|
|
||||||
|
if ( $unset && $has_rows ) {
|
||||||
|
update_option( TMDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '1', false );
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( self::MISC_BUCKET_DEFAULT_FLAG, '1', true );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reasons this site's wpdo_* tables cannot be dropped yet; empty means safe.
|
||||||
|
*
|
||||||
|
* Reads the site's own options rather than Mode_Manager / Feature_Flags,
|
||||||
|
* whose per-request caches do not follow switch_to_blog() during uninstall.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function uninstall_blockers(): array {
|
||||||
|
global $wpdb;
|
||||||
|
$blockers = array();
|
||||||
|
|
||||||
|
$modes = get_option( 'wpdo_bridge_modes', array() );
|
||||||
|
foreach ( is_array( $modes ) ? $modes : array() as $entity => $mode ) {
|
||||||
|
if ( 'aeav_only' === $mode ) {
|
||||||
|
$blockers[] = sprintf( '%1$s 的 bridge 模式是 aeav_only:資料只存在 wpdo_%1$s_* 扁平表。', $entity );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( array( 'wpdo_term_misc', 'wpdo_comment_misc' ) as $name ) {
|
||||||
|
if ( self::table_has_rows( $wpdb->prefix . $name ) ) {
|
||||||
|
$blockers[] = sprintf( '%s 有資料:這些 term / comment meta 只存在 misc 表。', $name );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$features = get_option( 'wpdo_features', array() );
|
||||||
|
foreach ( TMDO_Feature_Flags::ZONE_MODULES as $module ) {
|
||||||
|
$state = is_array( $features ) ? ( $features[ $module ] ?? 'idle' ) : 'idle';
|
||||||
|
if ( in_array( $state, array( 'cleanup', 'complete' ), true ) ) {
|
||||||
|
$blockers[] = sprintf( '區域模組 %1$s 處於 %2$s:讀取已改走 wpdo_* 表。', $module, $state );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $blockers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a table exists and holds at least one row.
|
||||||
|
*
|
||||||
|
* @param string $table Full table name.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function table_has_rows( string $table ): bool {
|
||||||
|
global $wpdb;
|
||||||
|
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" );
|
||||||
|
// phpcs:enable
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ─────────────────
|
// ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ─────────────────
|
||||||
@@ -753,18 +843,11 @@ class TMDO_Installer {
|
|||||||
$wpdb->prefix . 'wpdo_user_core_profile',
|
$wpdb->prefix . 'wpdo_user_core_profile',
|
||||||
$wpdb->prefix . 'wpdo_user_social',
|
$wpdb->prefix . 'wpdo_user_social',
|
||||||
$wpdb->prefix . 'wpdo_user_commerce',
|
$wpdb->prefix . 'wpdo_user_commerce',
|
||||||
$wpdb->prefix . 'wpdo_user_hp_user',
|
|
||||||
$wpdb->prefix . 'wpdo_user_admin_prefs',
|
$wpdb->prefix . 'wpdo_user_admin_prefs',
|
||||||
$wpdb->prefix . 'wpdo_post_wp_core',
|
$wpdb->prefix . 'wpdo_post_wp_core',
|
||||||
$wpdb->prefix . 'wpdo_post_attachment',
|
$wpdb->prefix . 'wpdo_post_attachment',
|
||||||
$wpdb->prefix . 'wpdo_post_wc_product',
|
$wpdb->prefix . 'wpdo_post_wc_product',
|
||||||
$wpdb->prefix . 'wpdo_post_hp_listing_core',
|
|
||||||
$wpdb->prefix . 'wpdo_post_hp_request_core',
|
|
||||||
$wpdb->prefix . 'wpdo_post_hp_vendor_core',
|
|
||||||
$wpdb->prefix . 'wpdo_post_nav_menu_item',
|
$wpdb->prefix . 'wpdo_post_nav_menu_item',
|
||||||
$wpdb->prefix . 'wpdo_hot_hp_listing',
|
|
||||||
$wpdb->prefix . 'wpdo_term_hp_taxonomy',
|
|
||||||
$wpdb->prefix . 'wpdo_comment_hp_review',
|
|
||||||
$wpdb->prefix . 'wpdo_term_misc',
|
$wpdb->prefix . 'wpdo_term_misc',
|
||||||
$wpdb->prefix . 'wpdo_comment_misc',
|
$wpdb->prefix . 'wpdo_comment_misc',
|
||||||
);
|
);
|
||||||
@@ -1272,7 +1355,10 @@ class TMDO_Installer {
|
|||||||
*/
|
*/
|
||||||
private static function run_dbdelta(): void {
|
private static function run_dbdelta(): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
// Same guard as install_v2_tables(): integration tests provide a stub dbDelta().
|
||||||
|
if ( ! function_exists( 'dbDelta' ) ) {
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
}
|
||||||
|
|
||||||
$charset = $wpdb->get_charset_collate();
|
$charset = $wpdb->get_charset_collate();
|
||||||
$p = $wpdb->prefix;
|
$p = $wpdb->prefix;
|
||||||
|
|||||||
@@ -303,23 +303,7 @@ final class TMDO_Post_Shadow_Verifier {
|
|||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
private static function group_post_type( string $group ): ?string {
|
private static function group_post_type( string $group ): ?string {
|
||||||
switch ( $group ) {
|
return TMDO_Post_Fields::post_type_for_group( $group );
|
||||||
case 'attachment':
|
|
||||||
return 'attachment';
|
|
||||||
case 'wc_product':
|
|
||||||
return 'product';
|
|
||||||
case 'hp_listing_core':
|
|
||||||
return 'hp_listing';
|
|
||||||
case 'hp_request_core':
|
|
||||||
return 'hp_request';
|
|
||||||
case 'hp_vendor_core':
|
|
||||||
return 'hp_vendor';
|
|
||||||
case 'nav_menu_item':
|
|
||||||
return 'nav_menu_item';
|
|
||||||
case 'wp_core':
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -529,7 +529,16 @@ class TMDO_REST_API {
|
|||||||
* @return WP_REST_Response REST response.
|
* @return WP_REST_Response REST response.
|
||||||
*/
|
*/
|
||||||
public function get_listings( WP_REST_Request $request ): WP_REST_Response {
|
public function get_listings( WP_REST_Request $request ): WP_REST_Response {
|
||||||
$post_type = $request->get_param( 'post_type' ) ?? 'hp_listing';
|
$post_type = $request->get_param( 'post_type' ) ?? self::default_post_type();
|
||||||
|
if ( ! post_type_exists( $post_type ) || ! is_post_type_viewable( $post_type ) ) {
|
||||||
|
return new WP_REST_Response(
|
||||||
|
array(
|
||||||
|
'code' => 'rest_invalid_post_type',
|
||||||
|
'message' => 'Invalid post type.',
|
||||||
|
),
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
// Defense-in-depth: clamp per_page even if validate_callback in listings_args() fails open.
|
// Defense-in-depth: clamp per_page even if validate_callback in listings_args() fails open.
|
||||||
// Filter `wpdo_rest_max_per_page` allows site owners to adjust the upper bound.
|
// Filter `wpdo_rest_max_per_page` allows site owners to adjust the upper bound.
|
||||||
$max_per_page = (int) apply_filters( 'wpdo_rest_max_per_page', 100 );
|
$max_per_page = (int) apply_filters( 'wpdo_rest_max_per_page', 100 );
|
||||||
@@ -1365,6 +1374,10 @@ class TMDO_REST_API {
|
|||||||
/**
|
/**
|
||||||
* Query Zone A flat table directly.
|
* Query Zone A flat table directly.
|
||||||
*
|
*
|
||||||
|
* Rows are joined to the posts table so only published posts of the
|
||||||
|
* requested type are listed (the hot table also holds drafts, private
|
||||||
|
* and trashed posts).
|
||||||
|
*
|
||||||
* @param string $post_type Post type slug.
|
* @param string $post_type Post type slug.
|
||||||
* @param int $per_page Number of items per page.
|
* @param int $per_page Number of items per page.
|
||||||
* @param int $offset Database offset.
|
* @param int $offset Database offset.
|
||||||
@@ -1396,6 +1409,8 @@ class TMDO_REST_API {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : '';
|
$where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : '';
|
||||||
|
$join_sql = "INNER JOIN `{$wpdb->posts}` p ON p.ID = t.post_id AND p.post_type = %s AND p.post_status = 'publish'";
|
||||||
|
array_unshift( $params, $post_type );
|
||||||
|
|
||||||
// Validate orderby against known columns + post_id.
|
// Validate orderby against known columns + post_id.
|
||||||
$allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) );
|
$allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) );
|
||||||
@@ -1404,16 +1419,12 @@ class TMDO_REST_API {
|
|||||||
|
|
||||||
// Count total.
|
// Count total.
|
||||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table from TMDO_Zone_Hot::table(); where_sql/orderby_col/order_dir are sanitized/validated.
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table from TMDO_Zone_Hot::table(); where_sql/orderby_col/order_dir are sanitized/validated.
|
||||||
if ( $params ) {
|
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` t {$join_sql} {$where_sql}", ...$params ) );
|
||||||
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` {$where_sql}", ...$params ) );
|
|
||||||
} else {
|
|
||||||
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch rows.
|
// Fetch rows.
|
||||||
$rows = $wpdb->get_results(
|
$rows = $wpdb->get_results(
|
||||||
$wpdb->prepare(
|
$wpdb->prepare(
|
||||||
"SELECT * FROM `{$table}` {$where_sql} ORDER BY `{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d",
|
"SELECT t.* FROM `{$table}` t {$join_sql} {$where_sql} ORDER BY t.`{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d",
|
||||||
...array_merge( $params, array( $per_page, $offset ) )
|
...array_merge( $params, array( $per_page, $offset ) )
|
||||||
),
|
),
|
||||||
ARRAY_A
|
ARRAY_A
|
||||||
@@ -1476,8 +1487,8 @@ class TMDO_REST_API {
|
|||||||
'id' => $post->ID,
|
'id' => $post->ID,
|
||||||
'post_type' => $post->post_type,
|
'post_type' => $post->post_type,
|
||||||
);
|
);
|
||||||
$cols = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
|
// Same show_in_rest restriction as the Zone A path.
|
||||||
foreach ( array_keys( $cols ) as $col ) {
|
foreach ( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) as $col ) {
|
||||||
$item[ $col ] = get_post_meta( $post->ID, $col, true );
|
$item[ $col ] = get_post_meta( $post->ID, $col, true );
|
||||||
}
|
}
|
||||||
$items[] = $item;
|
$items[] = $item;
|
||||||
@@ -1507,7 +1518,7 @@ class TMDO_REST_API {
|
|||||||
$min = $request->get_param( $col . '_min' );
|
$min = $request->get_param( $col . '_min' );
|
||||||
if ( null !== $min && is_numeric( $min ) ) {
|
if ( null !== $min && is_numeric( $min ) ) {
|
||||||
$filters[] = array(
|
$filters[] = array(
|
||||||
'sql' => "`{$col}` >= %f",
|
'sql' => "t.`{$col}` >= %f",
|
||||||
'value' => (float) $min,
|
'value' => (float) $min,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1515,7 +1526,7 @@ class TMDO_REST_API {
|
|||||||
$max = $request->get_param( $col . '_max' );
|
$max = $request->get_param( $col . '_max' );
|
||||||
if ( null !== $max && is_numeric( $max ) ) {
|
if ( null !== $max && is_numeric( $max ) ) {
|
||||||
$filters[] = array(
|
$filters[] = array(
|
||||||
'sql' => "`{$col}` <= %f",
|
'sql' => "t.`{$col}` <= %f",
|
||||||
'value' => (float) $max,
|
'value' => (float) $max,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1524,12 +1535,12 @@ class TMDO_REST_API {
|
|||||||
if ( null !== $exact && '' !== $exact ) {
|
if ( null !== $exact && '' !== $exact ) {
|
||||||
if ( is_numeric( $exact ) ) {
|
if ( is_numeric( $exact ) ) {
|
||||||
$filters[] = array(
|
$filters[] = array(
|
||||||
'sql' => "`{$col}` = %f",
|
'sql' => "t.`{$col}` = %f",
|
||||||
'value' => (float) $exact,
|
'value' => (float) $exact,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
$filters[] = array(
|
$filters[] = array(
|
||||||
'sql' => "`{$col}` = %s",
|
'sql' => "t.`{$col}` = %s",
|
||||||
'value' => (string) $exact,
|
'value' => (string) $exact,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1539,13 +1550,25 @@ class TMDO_REST_API {
|
|||||||
return $filters;
|
return $filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post type GET /listings serves when the request names none.
|
||||||
|
*
|
||||||
|
* Filter `tmdo_rest_default_post_type` lets an integration (e.g. the
|
||||||
|
* HivePress AddOn) make its own post type the default.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function default_post_type(): string {
|
||||||
|
return (string) apply_filters( 'tmdo_rest_default_post_type', 'post' );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Argument definitions for GET /listings.
|
* Argument definitions for GET /listings.
|
||||||
*/
|
*/
|
||||||
private function listings_args(): array {
|
private function listings_args(): array {
|
||||||
return array(
|
return array(
|
||||||
'post_type' => array(
|
'post_type' => array(
|
||||||
'default' => 'hp_listing',
|
'default' => self::default_post_type(),
|
||||||
'sanitize_callback' => 'sanitize_key',
|
'sanitize_callback' => 'sanitize_key',
|
||||||
),
|
),
|
||||||
'per_page' => array(
|
'per_page' => array(
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ class TMDO_Schema_Registry {
|
|||||||
*/
|
*/
|
||||||
private array $warm_fields = array();
|
private array $warm_fields = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Providers already told the Hot zone is frozen (once per request).
|
||||||
|
*
|
||||||
|
* @var array<string, true>
|
||||||
|
*/
|
||||||
|
private array $hot_deprecation_flagged = array();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private constructor — use instance() to get the singleton.
|
* Private constructor — use instance() to get the singleton.
|
||||||
*/
|
*/
|
||||||
@@ -138,6 +145,7 @@ class TMDO_Schema_Registry {
|
|||||||
switch ( $config['zone'] ) {
|
switch ( $config['zone'] ) {
|
||||||
case 'hot':
|
case 'hot':
|
||||||
$this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type'];
|
$this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type'];
|
||||||
|
$this->flag_hot_deprecation( $provider, $config['meta_key'] );
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'warm':
|
case 'warm':
|
||||||
@@ -145,11 +153,43 @@ class TMDO_Schema_Registry {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'cold':
|
case 'cold':
|
||||||
$this->cold_fields[ $config['post_type'] ][] = $config['meta_key'];
|
// 去重:hot 以 column 為 key、warm 以 meta_key 為 key,兩者天生冪等;
|
||||||
|
// 唯獨 cold 是 append,重複註冊會無限膨脹。而 `wpdo_register_fields`
|
||||||
|
// 本來就會被 fire 多次(core plugins_loaded:4 + late-bind safety net :30),
|
||||||
|
// 加上主外掛與 AddOn 可能同時註冊同一批欄位,實測曾出現同一 key 重複 4 次。
|
||||||
|
if ( ! isset( $this->cold_fields[ $config['post_type'] ] ) ) {
|
||||||
|
$this->cold_fields[ $config['post_type'] ] = array();
|
||||||
|
}
|
||||||
|
if ( ! in_array( $config['meta_key'], $this->cold_fields[ $config['post_type'] ], true ) ) {
|
||||||
|
$this->cold_fields[ $config['post_type'] ][] = $config['meta_key'];
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Hot zone is frozen (ADR-003): fields still register so existing hot
|
||||||
|
* data stays readable, but each provider gets one notice per request.
|
||||||
|
*
|
||||||
|
* @param string $provider Provider name.
|
||||||
|
* @param string $meta_key First hot meta_key seen from it.
|
||||||
|
*/
|
||||||
|
private function flag_hot_deprecation( string $provider, string $meta_key ): void {
|
||||||
|
if ( isset( $this->hot_deprecation_flagged[ $provider ] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->hot_deprecation_flagged[ $provider ] = true;
|
||||||
|
_doing_it_wrong(
|
||||||
|
__CLASS__ . '::register',
|
||||||
|
sprintf(
|
||||||
|
'Provider "%1$s" registered Hot zone field "%2$s". The Hot zone is frozen; register an entity group (TMDO_Entity_Registry::register_group) instead.',
|
||||||
|
esc_html( $provider ),
|
||||||
|
esc_html( $meta_key )
|
||||||
|
),
|
||||||
|
esc_html( TMDO_VERSION )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk register multiple fields.
|
* Bulk register multiple fields.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -60,20 +60,22 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
final class TMDO_Term_Comment_Backfill {
|
final class TMDO_Term_Comment_Backfill {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group → flat table suffix mapping. Mirror of TMDO_Hivepress_Term_Comment_Fields.
|
* Term and comment groups registered in the entity registry.
|
||||||
*
|
*
|
||||||
* @var array<string, array{entity_type:string, table_suffix:string}>
|
* @return array<string, string> Group name → entity type ('term' | 'comment').
|
||||||
*/
|
*/
|
||||||
private const GROUP_MAP = array(
|
public static function registered_groups(): array {
|
||||||
'hp_taxonomy' => array(
|
$groups = array();
|
||||||
'entity_type' => 'term',
|
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||||
'table_suffix' => 'wpdo_term_hp_taxonomy',
|
return $groups;
|
||||||
),
|
}
|
||||||
'hp_review' => array(
|
foreach ( array( 'term', 'comment' ) as $entity_type ) {
|
||||||
'entity_type' => 'comment',
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
|
||||||
'table_suffix' => 'wpdo_comment_hp_review',
|
$groups[ $group ] = $groups[ $group ] ?? $entity_type;
|
||||||
),
|
}
|
||||||
);
|
}
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run backfill for every registered term + comment group.
|
* Run backfill for every registered term + comment group.
|
||||||
@@ -83,8 +85,8 @@ final class TMDO_Term_Comment_Backfill {
|
|||||||
*/
|
*/
|
||||||
public static function backfill_all( bool $dry_run = false ): array {
|
public static function backfill_all( bool $dry_run = false ): array {
|
||||||
$results = array();
|
$results = array();
|
||||||
foreach ( self::GROUP_MAP as $group_name => $cfg ) {
|
foreach ( self::registered_groups() as $group_name => $entity_type ) {
|
||||||
$results[ $group_name ] = self::backfill_group( $cfg['entity_type'], $group_name, $dry_run );
|
$results[ $group_name ] = self::backfill_group( $entity_type, $group_name, $dry_run );
|
||||||
}
|
}
|
||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
@@ -110,7 +112,7 @@ final class TMDO_Term_Comment_Backfill {
|
|||||||
$result['error'] = 'Entity Registry unavailable';
|
$result['error'] = 'Entity Registry unavailable';
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
if ( ! isset( self::GROUP_MAP[ $group_name ] ) ) {
|
if ( ( self::registered_groups()[ $group_name ] ?? null ) !== $entity_type ) {
|
||||||
$result['error'] = "Unknown group: {$group_name}";
|
$result['error'] = "Unknown group: {$group_name}";
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
@@ -140,7 +142,7 @@ final class TMDO_Term_Comment_Backfill {
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
$flat_table = $wpdb->prefix . self::GROUP_MAP[ $group_name ]['table_suffix'];
|
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
|
||||||
|
|
||||||
// Count candidate entities — those with at least one of the registered keys in wp_*meta.
|
// Count candidate entities — those with at least one of the registered keys in wp_*meta.
|
||||||
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
||||||
|
|||||||
@@ -41,23 +41,6 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
|||||||
/** Default sample size per entity per cron tick. */
|
/** Default sample size per entity per cron tick. */
|
||||||
public const DEFAULT_SAMPLE_SIZE = 100;
|
public const DEFAULT_SAMPLE_SIZE = 100;
|
||||||
|
|
||||||
/**
|
|
||||||
* Group → flat table suffix mapping. Keep in sync with
|
|
||||||
* TMDO_Hivepress_Term_Comment_Fields registrations and the misc bucket.
|
|
||||||
*
|
|
||||||
* @var array<string, array{entity_type:string, table_suffix:string, has_misc:bool}>
|
|
||||||
*/
|
|
||||||
private const GROUP_MAP = array(
|
|
||||||
'hp_taxonomy' => array(
|
|
||||||
'entity_type' => 'term',
|
|
||||||
'table_suffix' => 'wpdo_term_hp_taxonomy',
|
|
||||||
),
|
|
||||||
'hp_review' => array(
|
|
||||||
'entity_type' => 'comment',
|
|
||||||
'table_suffix' => 'wpdo_comment_hp_review',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a sample-compare pass for a single entity group.
|
* Run a sample-compare pass for a single entity group.
|
||||||
*
|
*
|
||||||
@@ -215,11 +198,7 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
global $wpdb;
|
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $entity_type ) {
|
||||||
|
|
||||||
foreach ( self::GROUP_MAP as $group => $cfg ) {
|
|
||||||
$entity_type = $cfg['entity_type'];
|
|
||||||
|
|
||||||
// Only run when the entity is in shadow_read mode.
|
// Only run when the entity is in shadow_read mode.
|
||||||
if ( 'term' === $entity_type && ! $term_shadow ) {
|
if ( 'term' === $entity_type && ! $term_shadow ) {
|
||||||
continue;
|
continue;
|
||||||
@@ -232,7 +211,7 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
|||||||
if ( empty( $keys ) ) {
|
if ( empty( $keys ) ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
|
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||||
|
|
||||||
try {
|
try {
|
||||||
self::sample_compare( $entity_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
|
self::sample_compare( $entity_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
|
||||||
@@ -337,16 +316,14 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
|||||||
return array();
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
global $wpdb;
|
|
||||||
$out = array();
|
$out = array();
|
||||||
|
|
||||||
foreach ( self::GROUP_MAP as $group => $cfg ) {
|
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $entity_type ) {
|
||||||
$entity_type = $cfg['entity_type'];
|
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
|
||||||
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
|
|
||||||
if ( empty( $keys ) ) {
|
if ( empty( $keys ) ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
|
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$out[ $group ] = self::sample_compare( $entity_type, $group, $flat_table, $keys, $sample_size );
|
$out[ $group ] = self::sample_compare( $entity_type, $group, $flat_table, $keys, $sample_size );
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
|
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
|
||||||
*
|
*
|
||||||
* Registers 7 tests under Tools → Site Health → Status:
|
* Registers 10 tests under Tools → Site Health → Status:
|
||||||
* 1. wpdo_schema_drift (critical)
|
* 1. wpdo_schema_drift (critical)
|
||||||
* 2. wpdo_error_budget (recommended)
|
* 2. wpdo_error_budget (recommended)
|
||||||
* 3. wpdo_hook_conflicts (recommended)
|
* 3. wpdo_hook_conflicts (recommended)
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
* 5. wpdo_postmeta_explosion (recommended)
|
* 5. wpdo_postmeta_explosion (recommended)
|
||||||
* 6. wpdo_orphan_zone_rows (recommended)
|
* 6. wpdo_orphan_zone_rows (recommended)
|
||||||
* 7. wpdo_missing_snapshot (critical)
|
* 7. wpdo_missing_snapshot (critical)
|
||||||
|
* 8. wpdo_entity_group_collisions (recommended)
|
||||||
|
* 9. wpdo_orphaned_entity_groups (recommended)
|
||||||
|
* 10. wpdo_missing_integration_addons (recommended)
|
||||||
*
|
*
|
||||||
* Each test result is cached for 5 minutes to keep Site Health responsive.
|
* Each test result is cached for 5 minutes to keep Site Health responsive.
|
||||||
*
|
*
|
||||||
@@ -34,13 +37,16 @@ class TMDO_Site_Health {
|
|||||||
|
|
||||||
/** Test name → callable suffix mapping. */
|
/** Test name → callable suffix mapping. */
|
||||||
private const TESTS = array(
|
private const TESTS = array(
|
||||||
'wpdo_schema_drift' => 'check_schema_drift',
|
'wpdo_schema_drift' => 'check_schema_drift',
|
||||||
'wpdo_error_budget' => 'check_error_budget',
|
'wpdo_error_budget' => 'check_error_budget',
|
||||||
'wpdo_hook_conflicts' => 'check_hook_conflicts',
|
'wpdo_hook_conflicts' => 'check_hook_conflicts',
|
||||||
'wpdo_autoload_bloat' => 'check_autoload_bloat',
|
'wpdo_autoload_bloat' => 'check_autoload_bloat',
|
||||||
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
|
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
|
||||||
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
|
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
|
||||||
'wpdo_missing_snapshot' => 'check_missing_snapshot',
|
'wpdo_missing_snapshot' => 'check_missing_snapshot',
|
||||||
|
'wpdo_entity_group_collisions' => 'check_entity_group_collisions',
|
||||||
|
'wpdo_orphaned_entity_groups' => 'check_orphaned_entity_groups',
|
||||||
|
'wpdo_missing_integration_addons' => 'check_missing_integration_addons',
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +59,7 @@ class TMDO_Site_Health {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the 7 tests with WP Site Health.
|
* Register the 10 tests with WP Site Health.
|
||||||
*
|
*
|
||||||
* @param array $tests Existing tests.
|
* @param array $tests Existing tests.
|
||||||
* @return array
|
* @return array
|
||||||
@@ -76,13 +82,16 @@ class TMDO_Site_Health {
|
|||||||
*/
|
*/
|
||||||
private static function label_for( string $key ): string {
|
private static function label_for( string $key ): string {
|
||||||
$map = array(
|
$map = array(
|
||||||
'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ),
|
'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ),
|
||||||
'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ),
|
'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ),
|
||||||
'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ),
|
'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ),
|
||||||
'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ),
|
'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ),
|
||||||
'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ),
|
'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ),
|
||||||
'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ),
|
'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ),
|
||||||
'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ),
|
'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ),
|
||||||
|
'wpdo_entity_group_collisions' => __( 'WPDO entity group collisions', '2meet-data-optimizer' ),
|
||||||
|
'wpdo_orphaned_entity_groups' => __( 'WPDO orphaned entity groups', '2meet-data-optimizer' ),
|
||||||
|
'wpdo_missing_integration_addons' => __( 'WPDO integrations without their AddOn', '2meet-data-optimizer' ),
|
||||||
);
|
);
|
||||||
return $map[ $key ] ?? $key;
|
return $map[ $key ] ?? $key;
|
||||||
}
|
}
|
||||||
@@ -425,6 +434,118 @@ class TMDO_Site_Health {
|
|||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ─── Test 8: Entity group name collisions ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report entity groups registered twice with different definitions.
|
||||||
|
*
|
||||||
|
* Not cached: the collision list is in-memory state rebuilt on every
|
||||||
|
* request, so reading it is free and a transient would only go stale.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Site Health result.
|
||||||
|
*/
|
||||||
|
public static function check_entity_group_collisions(): array {
|
||||||
|
$collisions = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_collisions() : array();
|
||||||
|
if ( empty( $collisions ) ) {
|
||||||
|
return self::wrap( 'wpdo_entity_group_collisions', self::pass( __( 'No entity group name collisions.', '2meet-data-optimizer' ) ) );
|
||||||
|
}
|
||||||
|
$lines = array();
|
||||||
|
foreach ( $collisions as $c ) {
|
||||||
|
$lines[] = sprintf(
|
||||||
|
'%s/%s (+%s −%s ~%s)',
|
||||||
|
$c['entity_type'],
|
||||||
|
$c['group'],
|
||||||
|
implode( ',', $c['added'] ) ?: '-',
|
||||||
|
implode( ',', $c['removed'] ) ?: '-',
|
||||||
|
implode( ',', $c['changed'] ) ?: '-'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return self::wrap(
|
||||||
|
'wpdo_entity_group_collisions',
|
||||||
|
self::fail(
|
||||||
|
__( 'WPDO entity group name collisions', '2meet-data-optimizer' ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: list of entity/group names with field differences */
|
||||||
|
__( 'Another plugin registered these groups with a different field list; its definition was ignored and the first one kept: %s', '2meet-data-optimizer' ),
|
||||||
|
implode( '; ', $lines )
|
||||||
|
),
|
||||||
|
'recommended'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 9: Orphaned entity groups ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report flat-table groups kept alive from wpdo_registry_meta because no
|
||||||
|
* active plugin registers them any more.
|
||||||
|
*
|
||||||
|
* Not cached for the same reason as check_entity_group_collisions().
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Site Health result.
|
||||||
|
*/
|
||||||
|
public static function check_orphaned_entity_groups(): array {
|
||||||
|
$restored = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_restored_groups() : array();
|
||||||
|
if ( empty( $restored ) ) {
|
||||||
|
return self::wrap( 'wpdo_orphaned_entity_groups', self::pass( __( 'Every flat-table group is registered by an active plugin.', '2meet-data-optimizer' ) ) );
|
||||||
|
}
|
||||||
|
$lines = array();
|
||||||
|
foreach ( $restored as $r ) {
|
||||||
|
$lines[] = sprintf(
|
||||||
|
'%s/%s (%d fields%s%s)',
|
||||||
|
$r['entity_type'],
|
||||||
|
$r['group'],
|
||||||
|
$r['fields'],
|
||||||
|
$r['skipped'] ? ', skipped: ' . implode( ',', $r['skipped'] ) : '',
|
||||||
|
$r['native'] ? ', left on native meta (newer data there): ' . implode( ',', $r['native'] ) : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return self::wrap(
|
||||||
|
'wpdo_orphaned_entity_groups',
|
||||||
|
self::fail(
|
||||||
|
__( 'WPDO entity groups restored from stored definitions', '2meet-data-optimizer' ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: list of entity/group names */
|
||||||
|
__( 'No active plugin registers these groups, so their stored definitions were re-applied to keep the flat-table data readable. Reactivate the owning plugin, or write the data back to native meta before removing it: %s', '2meet-data-optimizer' ),
|
||||||
|
implode( '; ', $lines )
|
||||||
|
),
|
||||||
|
'recommended'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 10: Integrations without their AddOn ──────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report HivePress / WooCommerce running while their AddOn is inactive.
|
||||||
|
*
|
||||||
|
* Not cached: two class_exists() probes.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Site Health result.
|
||||||
|
*/
|
||||||
|
public static function check_missing_integration_addons(): array {
|
||||||
|
$missing = class_exists( 'TMDO_Compatibility' ) ? TMDO_Compatibility::missing_integration_addons() : array();
|
||||||
|
if ( empty( $missing ) ) {
|
||||||
|
return self::wrap( 'wpdo_missing_integration_addons', self::pass( __( 'Every detected integration has its AddOn active.', '2meet-data-optimizer' ) ) );
|
||||||
|
}
|
||||||
|
$lines = array();
|
||||||
|
foreach ( $missing as $plugin => $addon ) {
|
||||||
|
$lines[] = "{$plugin} → {$addon}";
|
||||||
|
}
|
||||||
|
return self::wrap(
|
||||||
|
'wpdo_missing_integration_addons',
|
||||||
|
self::fail(
|
||||||
|
__( 'WPDO integrations running without their AddOn', '2meet-data-optimizer' ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: "Plugin → AddOn" pairs */
|
||||||
|
__( 'These plugins are active but their AddOn is not, so their fields are not optimised: %s. Activate the AddOn.', '2meet-data-optimizer' ),
|
||||||
|
implode( '; ', $lines )
|
||||||
|
),
|
||||||
|
'recommended'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap a raw check result with required Site Health envelope fields.
|
* Wrap a raw check result with required Site Health envelope fields.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ final class TMDO_Entity_Registry {
|
|||||||
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
|
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
|
||||||
private static array $pending_schemas = array();
|
private static array $pending_schemas = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, array<string, mixed>>> 實體類型 → meta_key → 欄位定義(僅未限定 post_type 的群組)*/
|
||||||
|
private static array $unscoped_index = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, array<string, array<string, mixed>>>> 實體類型 → post_type → meta_key → 欄位定義 */
|
||||||
|
private static array $scoped_index = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, string[]>> 實體類型 → 群組名 → 限定的 post_type */
|
||||||
|
private static array $scopes = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, array<string, string[]>>> 實體類型 → 群組名 → 索引名 → 欄位 key */
|
||||||
|
private static array $indexes = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
|
||||||
|
private static array $collisions = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> "entity/group" → 從 wpdo_registry_meta 還原的孤兒群組 */
|
||||||
|
private static array $restored = array();
|
||||||
|
|
||||||
/** 合法的欄位型別 */
|
/** 合法的欄位型別 */
|
||||||
public const VALID_TYPES = array(
|
public const VALID_TYPES = array(
|
||||||
'text',
|
'text',
|
||||||
@@ -49,11 +67,20 @@ final class TMDO_Entity_Registry {
|
|||||||
/** 合法的實體類型 */
|
/** 合法的實體類型 */
|
||||||
public const VALID_ENTITY_TYPES = array( 'post', 'user', 'term', 'comment' );
|
public const VALID_ENTITY_TYPES = array( 'post', 'user', 'term', 'comment' );
|
||||||
|
|
||||||
|
/** Option:'1' 時 get_field() 依傳入的 post_type 解析(見 get_field())。 */
|
||||||
|
public const SCOPED_ROUTING_OPTION = 'tmdo_post_scoped_routing';
|
||||||
|
|
||||||
public static function init(): void {
|
public static function init(): void {
|
||||||
self::$adapters = array();
|
self::$adapters = array();
|
||||||
self::$groups = array();
|
self::$groups = array();
|
||||||
self::$field_index = array();
|
self::$field_index = array();
|
||||||
|
self::$unscoped_index = array();
|
||||||
|
self::$scoped_index = array();
|
||||||
|
self::$scopes = array();
|
||||||
|
self::$indexes = array();
|
||||||
self::$pending_schemas = array();
|
self::$pending_schemas = array();
|
||||||
|
self::$collisions = array();
|
||||||
|
self::$restored = array();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
@@ -92,10 +119,17 @@ final class TMDO_Entity_Registry {
|
|||||||
* - unique (bool) 唯一索引
|
* - unique (bool) 唯一索引
|
||||||
* - options (array) enum 選項
|
* - options (array) enum 選項
|
||||||
* - label (string) 顯示名稱
|
* - label (string) 顯示名稱
|
||||||
|
* @param array<string, mixed> $args 選填:post_types (string[]) — 群組只套用於這些 post_type。
|
||||||
|
* 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬;
|
||||||
|
* 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。
|
||||||
|
* 不列入欄位定義,所以不影響 schema hash 與衝突比對。
|
||||||
|
* indexes (array<string, string[]>) — 複合索引:名稱 → 欄位 key(依序);
|
||||||
|
* 含未知欄位的索引會被略過。同樣不影響 schema hash,
|
||||||
|
* 由 TMDO_Schema_Manager::ensure_group_indexes() 補建到既有表。
|
||||||
*
|
*
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function register_group( string $entity_type, string $group_name, array $fields ): bool {
|
public static function register_group( string $entity_type, string $group_name, array $fields, array $args = array() ): bool {
|
||||||
|
|
||||||
if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) {
|
if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) {
|
||||||
return false;
|
return false;
|
||||||
@@ -105,12 +139,54 @@ final class TMDO_Entity_Registry {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 防重複登錄
|
$normalized = self::normalize_fields( $entity_type, $group_name, $fields );
|
||||||
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
|
|
||||||
|
if ( empty( $normalized ) ) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 驗證並正規化欄位定義
|
// 防重複登錄:相同定義(init:1 重新觸發)靜默略過;定義不同則記錄衝突,保留先註冊者。
|
||||||
|
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
|
||||||
|
if ( self::$groups[ $entity_type ][ $group_name ] !== $normalized ) {
|
||||||
|
self::record_collision( $entity_type, $group_name, self::$groups[ $entity_type ][ $group_name ], $normalized );
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建立索引以供快速查詢
|
||||||
|
$post_types = array_values( array_filter( array_map( 'strval', (array) ( $args['post_types'] ?? array() ) ) ) );
|
||||||
|
if ( $post_types ) {
|
||||||
|
self::$scopes[ $entity_type ][ $group_name ] = $post_types;
|
||||||
|
}
|
||||||
|
$keys = array_column( $normalized, 'key' );
|
||||||
|
foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) {
|
||||||
|
$columns = array_values( array_map( 'strval', (array) $columns ) );
|
||||||
|
if ( $columns && ! array_diff( $columns, $keys ) ) {
|
||||||
|
self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ( $normalized as $normalized_field ) {
|
||||||
|
self::index_field( $entity_type, $normalized_field, $post_types );
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$groups[ $entity_type ][ $group_name ] = $normalized;
|
||||||
|
|
||||||
|
// 加入待建表佇列
|
||||||
|
self::$pending_schemas[] = array(
|
||||||
|
'type' => $entity_type,
|
||||||
|
'group' => $group_name,
|
||||||
|
'fields' => $normalized,
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驗證並正規化欄位定義(略過缺 key/type 或型別不合法者)。
|
||||||
|
* @param array<int, array<string, mixed>> $fields
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function normalize_fields( string $entity_type, string $group_name, array $fields ): array {
|
||||||
$normalized = array();
|
$normalized = array();
|
||||||
|
|
||||||
foreach ( $fields as $field ) {
|
foreach ( $fields as $field ) {
|
||||||
@@ -141,27 +217,148 @@ final class TMDO_Entity_Registry {
|
|||||||
$normalized_field['entity_type'] = $entity_type;
|
$normalized_field['entity_type'] = $entity_type;
|
||||||
|
|
||||||
$normalized[] = $normalized_field;
|
$normalized[] = $normalized_field;
|
||||||
|
|
||||||
// 建立索引以供快速查詢
|
|
||||||
self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( empty( $normalized ) ) {
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 還原孤兒群組:依 wpdo_registry_meta 保存的定義重新登錄,只供讀寫路由用。
|
||||||
|
*
|
||||||
|
* 與 register_group() 的差異:不進 pending_schemas(絕不觸發 DDL),且已被
|
||||||
|
* 其他存活群組管理的 key 一律略過,不覆蓋。$native_keys 列出原生 meta 仍有
|
||||||
|
* 資料的 key:那些值比扁平表新(孤兒期間寫入),必須留在原生 meta。
|
||||||
|
*
|
||||||
|
* @param string[] $native_keys 原生 meta 仍有資料、不可還原的 key。
|
||||||
|
* @return bool 有登錄任何欄位時為 true
|
||||||
|
* @param array<int, array<string, mixed>> $fields
|
||||||
|
*/
|
||||||
|
public static function restore_group( string $entity_type, string $group_name, array $fields, array $native_keys = array() ): bool {
|
||||||
|
if ( ! isset( self::$adapters[ $entity_type ] ) || isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
self::$groups[ $entity_type ][ $group_name ] = $normalized;
|
$kept = array();
|
||||||
|
$skipped = array();
|
||||||
|
$native = array();
|
||||||
|
foreach ( self::normalize_fields( $entity_type, $group_name, $fields ) as $field ) {
|
||||||
|
if ( isset( self::$field_index[ $entity_type ][ $field['key'] ] ) ) {
|
||||||
|
$skipped[] = (string) $field['key'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( in_array( $field['key'], $native_keys, true ) ) {
|
||||||
|
$native[] = (string) $field['key'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$kept[] = $field;
|
||||||
|
}
|
||||||
|
|
||||||
// 加入待建表佇列
|
$id = $entity_type . '/' . $group_name;
|
||||||
self::$pending_schemas[] = array(
|
if ( empty( $kept ) ) {
|
||||||
'type' => $entity_type,
|
// 整組都留在原生 meta 時仍要回報:扁平表的舊資料需要人工處理。
|
||||||
'group' => $group_name,
|
if ( $native ) {
|
||||||
'fields' => $normalized,
|
self::$restored[ $id ] = array(
|
||||||
|
'entity_type' => $entity_type,
|
||||||
|
'group' => $group_name,
|
||||||
|
'fields' => 0,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'native' => $native,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $kept as $field ) {
|
||||||
|
self::index_field( $entity_type, $field, array() );
|
||||||
|
}
|
||||||
|
self::$groups[ $entity_type ][ $group_name ] = $kept;
|
||||||
|
|
||||||
|
self::$restored[ $id ] = array(
|
||||||
|
'entity_type' => $entity_type,
|
||||||
|
'group' => $group_name,
|
||||||
|
'fields' => count( $kept ),
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'native' => $native,
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把欄位加入索引。field_index 保留舊語意(整個實體共用、後註冊者覆蓋),
|
||||||
|
* 供不帶 post_type 的查詢使用;另外依是否限定 post_type 分別建索引。
|
||||||
|
*
|
||||||
|
* @param string[] $post_types 群組限定的 post_type;空陣列表示不限定。
|
||||||
|
* @param array<string, mixed> $field
|
||||||
|
*/
|
||||||
|
private static function index_field( string $entity_type, array $field, array $post_types ): void {
|
||||||
|
self::$field_index[ $entity_type ][ $field['key'] ] = $field;
|
||||||
|
|
||||||
|
if ( empty( $post_types ) ) {
|
||||||
|
self::$unscoped_index[ $entity_type ][ $field['key'] ] = $field;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ( $post_types as $post_type ) {
|
||||||
|
self::$scoped_index[ $entity_type ][ $post_type ][ $field['key'] ] = $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本請求從 wpdo_registry_meta 還原的孤兒群組。
|
||||||
|
*
|
||||||
|
* @return array<int, array{entity_type:string, group:string, fields:int, skipped:string[], native:string[]}>
|
||||||
|
*/
|
||||||
|
public static function get_restored_groups(): array {
|
||||||
|
return array_values( self::$restored );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。
|
||||||
|
*
|
||||||
|
* 註冊每個請求都會跑,warning 以 transient 節流為每天一次,避免灌爆 wpdo_errors。
|
||||||
|
* @param array<int, array<string, mixed>> $existing
|
||||||
|
* @param array<int, array<string, mixed>> $incoming
|
||||||
|
*/
|
||||||
|
private static function record_collision( string $entity_type, string $group_name, array $existing, array $incoming ): void {
|
||||||
|
$id = $entity_type . '/' . $group_name;
|
||||||
|
if ( isset( self::$collisions[ $id ] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$old = array_column( $existing, null, 'key' );
|
||||||
|
$new = array_column( $incoming, null, 'key' );
|
||||||
|
|
||||||
|
$changed = array();
|
||||||
|
foreach ( array_intersect_key( $new, $old ) as $key => $field ) {
|
||||||
|
if ( $field !== $old[ $key ] ) {
|
||||||
|
$changed[] = (string) $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$collisions[ $id ] = array(
|
||||||
|
'entity_type' => $entity_type,
|
||||||
|
'group' => $group_name,
|
||||||
|
'added' => array_map( 'strval', array_keys( array_diff_key( $new, $old ) ) ),
|
||||||
|
'removed' => array_map( 'strval', array_keys( array_diff_key( $old, $new ) ) ),
|
||||||
|
'changed' => $changed,
|
||||||
|
);
|
||||||
|
|
||||||
|
$throttle = 'tmdo_group_collision_' . md5( (string) wp_json_encode( self::$collisions[ $id ] ) );
|
||||||
|
if ( class_exists( 'TMDO_Logger' ) && false === get_transient( $throttle ) ) {
|
||||||
|
set_transient( $throttle, 1, DAY_IN_SECONDS );
|
||||||
|
TMDO_Logger::warning( 'entity_group_collision', self::$collisions[ $id ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本請求偵測到的同名群組衝突(第二份定義已被捨棄)。
|
||||||
|
*
|
||||||
|
* @return array<int, array{entity_type:string, group:string, added:string[], removed:string[], changed:string[]}>
|
||||||
|
*/
|
||||||
|
public static function get_collisions(): array {
|
||||||
|
return array_values( self::$collisions );
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
// 查詢 API
|
// 查詢 API
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
@@ -169,10 +366,21 @@ final class TMDO_Entity_Registry {
|
|||||||
/**
|
/**
|
||||||
* 依 meta_key 查詢欄位定義
|
* 依 meta_key 查詢欄位定義
|
||||||
*
|
*
|
||||||
|
* 傳入 $post_type 且 SCOPED_ROUTING_OPTION 開啟時:先找限定該 post_type 的群組,
|
||||||
|
* 再找不限定的群組,都沒有則為 null。否則沿用舊語意(整個實體共用索引,
|
||||||
|
* 共用 key 由最後註冊的群組擁有)。舊資料可能已依舊語意散落在別的群組表,
|
||||||
|
* 所以既有站要等資料對齊後才開啟 option。
|
||||||
|
*
|
||||||
|
* @param string $post_type 選填:post 的 post_type。
|
||||||
* @return array|null 欄位定義,或 null(非 UAE 管理欄位)
|
* @return array|null 欄位定義,或 null(非 UAE 管理欄位)
|
||||||
*/
|
*/
|
||||||
public static function get_field( string $entity_type, string $meta_key ): ?array {
|
public static function get_field( string $entity_type, string $meta_key, string $post_type = '' ): ?array {
|
||||||
return self::$field_index[ $entity_type ][ $meta_key ] ?? null;
|
if ( '' === $post_type || '1' !== (string) get_option( self::SCOPED_ROUTING_OPTION, '0' ) ) {
|
||||||
|
return self::$field_index[ $entity_type ][ $meta_key ] ?? null;
|
||||||
|
}
|
||||||
|
return self::$scoped_index[ $entity_type ][ $post_type ][ $meta_key ]
|
||||||
|
?? self::$unscoped_index[ $entity_type ][ $meta_key ]
|
||||||
|
?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,6 +392,24 @@ final class TMDO_Entity_Registry {
|
|||||||
return array_keys( self::$groups[ $entity_type ] ?? array() );
|
return array_keys( self::$groups[ $entity_type ] ?? array() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群組宣告的複合索引。
|
||||||
|
*
|
||||||
|
* @return array<string, string[]> 索引名 → 欄位 key
|
||||||
|
*/
|
||||||
|
public static function get_group_indexes( string $entity_type, string $group_name ): array {
|
||||||
|
return self::$indexes[ $entity_type ][ $group_name ] ?? array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群組限定的 post_type(未限定時為空陣列)。
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function get_group_post_types( string $entity_type, string $group_name ): array {
|
||||||
|
return self::$scopes[ $entity_type ][ $group_name ] ?? array();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得特定群組的完整欄位定義
|
* 取得特定群組的完整欄位定義
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ final class TMDO_Hook_Bus {
|
|||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
|
||||||
if ( ! $field_def ) {
|
if ( ! $field_def ) {
|
||||||
return $check; // 非管理欄位,放行
|
return $check; // 非管理欄位,放行
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ final class TMDO_Hook_Bus {
|
|||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
|
||||||
if ( ! $field_def ) {
|
if ( ! $field_def ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
@@ -328,19 +328,25 @@ final class TMDO_Hook_Bus {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 單一 upsert,避免 SELECT→UPDATE/INSERT 的 read-modify-write race。
|
// 單一 upsert,避免 SELECT→UPDATE/INSERT 的 read-modify-write race。
|
||||||
|
// post:新列一併帶入 post_status / post_date 副本(只在 insert 時寫入)。
|
||||||
|
$extra = 'post' === $type ? TMDO_Post_Status_Sync::insert_columns( $entity_id, $group ) : array();
|
||||||
$result = TMDO_DB::upsert(
|
$result = TMDO_DB::upsert(
|
||||||
$table,
|
$table,
|
||||||
array(
|
array_merge(
|
||||||
$id_col => $entity_id,
|
array(
|
||||||
$col => $db_value,
|
$id_col => $entity_id,
|
||||||
|
$col => $db_value,
|
||||||
|
),
|
||||||
|
$extra
|
||||||
),
|
),
|
||||||
array( $col ),
|
array( $col ),
|
||||||
$id_col,
|
$id_col,
|
||||||
array( '%d', $format )
|
array_merge( array( '%d', $format ), array_fill( 0, count( $extra ), '%s' ) )
|
||||||
);
|
);
|
||||||
|
|
||||||
// 清除快取
|
// 清除快取
|
||||||
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
|
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
|
||||||
|
self::touch_posts_last_changed( $type );
|
||||||
|
|
||||||
// 短路回傳 true(WP 認為寫入成功)
|
// 短路回傳 true(WP 認為寫入成功)
|
||||||
return $result !== false;
|
return $result !== false;
|
||||||
@@ -374,7 +380,7 @@ final class TMDO_Hook_Bus {
|
|||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
|
||||||
if ( ! $field_def ) {
|
if ( ! $field_def ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
@@ -471,7 +477,7 @@ final class TMDO_Hook_Bus {
|
|||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
|
||||||
if ( ! $field_def ) {
|
if ( ! $field_def ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
@@ -569,6 +575,8 @@ final class TMDO_Hook_Bus {
|
|||||||
TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group );
|
TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::touch_posts_last_changed( $type );
|
||||||
|
|
||||||
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
|
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
|
||||||
|
|
||||||
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
|
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
|
||||||
@@ -608,6 +616,20 @@ final class TMDO_Hook_Bus {
|
|||||||
// 工具方法
|
// 工具方法
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat post writes skip update_metadata()'s own hooks in aeav_only, so
|
||||||
|
* move posts' last_changed here: caches keyed on it (WP query caches,
|
||||||
|
* routed found-rows counts) must not outlive the write.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function touch_posts_last_changed( string $type ): void {
|
||||||
|
if ( 'post' === $type ) {
|
||||||
|
wp_cache_set_posts_last_changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 從當前 filter 名稱推斷實體類型
|
* 從當前 filter 名稱推斷實體類型
|
||||||
* 例:update_user_metadata → user
|
* 例:update_user_metadata → user
|
||||||
@@ -622,6 +644,20 @@ final class TMDO_Hook_Bus {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。
|
||||||
|
*
|
||||||
|
* 先做不帶 post_type 的查詢:大多數 key 不受管理,可省下 get_post_type()。
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
|
||||||
|
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||||
|
if ( null === $field_def || 'post' !== $type ) {
|
||||||
|
return $field_def;
|
||||||
|
}
|
||||||
|
return TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直接讀取(繞過 WP filter 系統)
|
* 直接讀取(繞過 WP filter 系統)
|
||||||
* 供 wpdo_get_meta() 便利函式使用,性能更好
|
* 供 wpdo_get_meta() 便利函式使用,性能更好
|
||||||
@@ -646,7 +682,7 @@ final class TMDO_Hook_Bus {
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $key );
|
$field_def = self::field_for( $type, $entity_id, $key );
|
||||||
if ( ! $field_def ) {
|
if ( ! $field_def ) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,16 +65,15 @@ final class TMDO_Mode_Manager {
|
|||||||
private static ?array $cache = null;
|
private static ?array $cache = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 預設模式(v2.5.4 起):
|
* 預設模式:全部 disabled,由管理者明確選擇後才啟用 entity bridge。
|
||||||
* post → disabled(由 Legacy Feature_Flags FSM 管理,不由 Mode_Manager 控制)
|
* 已存在 wpdo_bridge_modes 的站台(包含 aeav_only)以儲存值為準,不受影響。
|
||||||
* user / term / comment → dual_write(安全雙寫:寫入 flat table + 原生 EAV,讀取仍走 EAV)
|
|
||||||
*/
|
*/
|
||||||
private static function defaults(): array {
|
private static function defaults(): array {
|
||||||
return array(
|
return array(
|
||||||
'post' => self::MODE_DISABLED,
|
'post' => self::MODE_DISABLED,
|
||||||
'user' => self::MODE_DUAL_WRITE,
|
'user' => self::MODE_DISABLED,
|
||||||
'term' => self::MODE_DUAL_WRITE,
|
'term' => self::MODE_DISABLED,
|
||||||
'comment' => self::MODE_DUAL_WRITE,
|
'comment' => self::MODE_DISABLED,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Copies of posts.post_status / post_date on post entity tables (O-1).
|
||||||
|
*
|
||||||
|
* With the copies (and their `(post_status, post_date)` index) a routed search
|
||||||
|
* can filter and sort inside the flat table instead of joining back to posts.
|
||||||
|
*
|
||||||
|
* Columns only reach a table two ways, never as DDL on a populated table in a
|
||||||
|
* web request:
|
||||||
|
* - a brand-new (empty) group table gets them when it is created;
|
||||||
|
* - `wp tmdo post-status-sync` adds them to existing tables and backfills.
|
||||||
|
*
|
||||||
|
* Per-group state lives in the TMDO_Post_Status_Sync::OPTION option:
|
||||||
|
* 'columns' (added, backfill pending) or 'ready' (every row copied).
|
||||||
|
* Afterwards transition_post_status keeps the copies current, and flat writes
|
||||||
|
* that create a row fill them in.
|
||||||
|
*
|
||||||
|
* @package TMDO
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps post_status / post_date copies on post entity tables.
|
||||||
|
*/
|
||||||
|
final class TMDO_Post_Status_Sync {
|
||||||
|
|
||||||
|
public const OPTION = 'tmdo_post_status_columns';
|
||||||
|
|
||||||
|
private const INDEX = 'idx_post_status_date';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook the sync into WordPress.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(): void {
|
||||||
|
add_action( 'transition_post_status', array( __CLASS__, 'on_transition' ), 10, 3 );
|
||||||
|
add_action( 'wpdo_schema_updated', array( __CLASS__, 'on_schema_updated' ), 10, 3 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* '' (no columns), 'columns' or 'ready'.
|
||||||
|
*
|
||||||
|
* @param string $group Post group name.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function state( string $group ): string {
|
||||||
|
$states = get_option( self::OPTION, array() );
|
||||||
|
return is_array( $states ) ? (string) ( $states[ $group ] ?? '' ) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every post status change or save: refresh the copies in each table that has them.
|
||||||
|
*
|
||||||
|
* @param string $new_status New status.
|
||||||
|
* @param string $old_status Old status.
|
||||||
|
* @param object $post Saved post.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function on_transition( $new_status, $old_status, $post ): void {
|
||||||
|
global $wpdb;
|
||||||
|
unset( $new_status, $old_status );
|
||||||
|
foreach ( self::groups_with_columns() as $group ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$post->post_status,
|
||||||
|
$post->post_date,
|
||||||
|
$post->ID
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A post group table was just created or altered: an empty one gets the
|
||||||
|
* columns now; one holding rows waits for the CLI.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $table Table name.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function on_schema_updated( $entity_type, $group, $table ): void {
|
||||||
|
global $wpdb;
|
||||||
|
if ( 'post' !== $entity_type || '' !== self::state( $group ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( self::add_columns( $group ) ) {
|
||||||
|
self::set_state( $group, 'ready' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies for a row a flat write is about to create (empty when the
|
||||||
|
* group's table has no such columns).
|
||||||
|
*
|
||||||
|
* @param int $post_id Post ID.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return array{post_status?:string, post_date?:string}
|
||||||
|
*/
|
||||||
|
public static function insert_columns( int $post_id, string $group ): array {
|
||||||
|
if ( '' === self::state( $group ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$post = get_post( $post_id );
|
||||||
|
if ( ! $post ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'post_status' => $post->post_status,
|
||||||
|
'post_date' => $post->post_date,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the columns to every post group table and copy posts' values in.
|
||||||
|
*
|
||||||
|
* @param bool $dry_run Count mismatching rows only.
|
||||||
|
* @param int $batch Rows per UPDATE.
|
||||||
|
* @return array<string, array{rows:int, mismatched:int}>
|
||||||
|
*/
|
||||||
|
public static function run( bool $dry_run = false, int $batch = 1000 ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$batch = max( 1, $batch );
|
||||||
|
$report = array();
|
||||||
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$report[ $group ] = array(
|
||||||
|
'rows' => $rows,
|
||||||
|
'mismatched' => '' === self::state( $group ) ? $rows : self::mismatches( $group ),
|
||||||
|
);
|
||||||
|
if ( $dry_run || ! self::add_columns( $group ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$max = (int) $wpdb->get_var( "SELECT MAX(id) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
for ( $from = 0; $from < $max; $from += $batch ) {
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id SET t.post_status = p.post_status, t.post_date = p.post_date WHERE t.id > %d AND t.id <= %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$from,
|
||||||
|
$from + $batch
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self::set_state( $group, 'ready' );
|
||||||
|
}
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows whose copies differ from posts (rows of deleted posts excluded).
|
||||||
|
*
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public static function mismatches( string $group ): int {
|
||||||
|
global $wpdb;
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
return (int) $wpdb->get_var(
|
||||||
|
"SELECT COUNT(*) FROM `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id WHERE NOT ( t.post_status <=> p.post_status ) OR NOT ( t.post_date <=> p.post_date )" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add whichever of the two columns and the index the table lacks.
|
||||||
|
*
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return bool False when the table is missing or the ALTER failed.
|
||||||
|
*/
|
||||||
|
private static function add_columns( string $group ): bool {
|
||||||
|
global $wpdb;
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$indexes = array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$changes = array();
|
||||||
|
if ( ! in_array( 'post_status', $columns, true ) ) {
|
||||||
|
// Same collation as posts.post_status: the table default may differ,
|
||||||
|
// and comparing the two columns would then fail (illegal mix of collations).
|
||||||
|
$source = $wpdb->get_row( "SHOW FULL COLUMNS FROM `{$wpdb->posts}` LIKE 'post_status'", ARRAY_A );
|
||||||
|
$collation = preg_replace( '/[^A-Za-z0-9_]/', '', (string) ( $source['Collation'] ?? '' ) );
|
||||||
|
$changes[] = 'ADD COLUMN `post_status` VARCHAR(20)' . ( '' !== $collation ? " COLLATE {$collation}" : '' ) . ' DEFAULT NULL';
|
||||||
|
}
|
||||||
|
if ( ! in_array( 'post_date', $columns, true ) ) {
|
||||||
|
$changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL';
|
||||||
|
}
|
||||||
|
if ( ! in_array( self::INDEX, $indexes, true ) ) {
|
||||||
|
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`)';
|
||||||
|
}
|
||||||
|
if ( $changes && false === $wpdb->query( "ALTER TABLE `{$table}` " . implode( ', ', $changes ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fixed DDL on a registry table name.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ( '' === self::state( $group ) ) {
|
||||||
|
self::set_state( $group, 'columns' );
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post groups whose table has the columns.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function groups_with_columns(): array {
|
||||||
|
$states = get_option( self::OPTION, array() );
|
||||||
|
return is_array( $states ) ? array_keys( $states ) : array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a group's state.
|
||||||
|
*
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $state 'columns' | 'ready'.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function set_state( string $group, string $state ): void {
|
||||||
|
$states = get_option( self::OPTION, array() );
|
||||||
|
$states = is_array( $states ) ? $states : array();
|
||||||
|
$states[ $group ] = $state;
|
||||||
|
update_option( self::OPTION, $states, true );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ defined( 'ABSPATH' ) || exit;
|
|||||||
|
|
||||||
final class TMDO_Schema_Manager {
|
final class TMDO_Schema_Manager {
|
||||||
|
|
||||||
|
/** Option: "entity/group" → md5 of the composite index definition already applied. */
|
||||||
|
public const INDEXES_OPTION = 'tmdo_entity_indexes';
|
||||||
|
|
||||||
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
|
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
|
||||||
private static array $table_exists_cache = array();
|
private static array $table_exists_cache = array();
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ final class TMDO_Schema_Manager {
|
|||||||
$schema['group'],
|
$schema['group'],
|
||||||
$schema['fields']
|
$schema['fields']
|
||||||
);
|
);
|
||||||
|
self::ensure_group_indexes( $schema['type'], $schema['group'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
TMDO_Entity_Registry::clear_pending_schemas();
|
TMDO_Entity_Registry::clear_pending_schemas();
|
||||||
@@ -164,6 +168,50 @@ final class TMDO_Schema_Manager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the group's declared composite indexes that its table lacks.
|
||||||
|
*
|
||||||
|
* Indexes are not part of the schema hash, so an existing table (which
|
||||||
|
* may already hold aeav_only data) gets them in place — no rebuild. The
|
||||||
|
* applied definition is remembered per group (one autoloaded option), so
|
||||||
|
* requests only run SHOW INDEX / ALTER when the declaration changes.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group_name Group name.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function ensure_group_indexes( string $entity_type, string $group_name ): void {
|
||||||
|
global $wpdb;
|
||||||
|
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
|
||||||
|
if ( empty( $declared ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$id = "{$entity_type}/{$group_name}";
|
||||||
|
$signature = md5( (string) wp_json_encode( $declared ) );
|
||||||
|
$applied = get_option( self::INDEXES_OPTION, array() );
|
||||||
|
$applied = is_array( $applied ) ? $applied : array();
|
||||||
|
if ( ( $applied[ $id ] ?? '' ) === $signature ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$table = self::get_table_name( $entity_type, $group_name );
|
||||||
|
if ( ! self::table_exists( $table ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
|
||||||
|
foreach ( $declared as $name => $keys ) {
|
||||||
|
$index = 'cidx_' . self::sanitize_column_name( $name );
|
||||||
|
if ( in_array( $index, $existing, true ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
|
||||||
|
$wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" );
|
||||||
|
}
|
||||||
|
|
||||||
|
$applied[ $id ] = $signature;
|
||||||
|
update_option( self::INDEXES_OPTION, $applied, true );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 計算 Schema Hash(用於偵測欄位變動)
|
* 計算 Schema Hash(用於偵測欄位變動)
|
||||||
*/
|
*/
|
||||||
@@ -268,6 +316,113 @@ final class TMDO_Schema_Manager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 還原孤兒群組:扁平表還在、定義存在 wpdo_registry_meta,但已沒有外掛註冊。
|
||||||
|
*
|
||||||
|
* 在所有註冊都完成後(init:1 晚綁定)呼叫。只處理 aeav_only 的實體:其他
|
||||||
|
* 模式仍會寫原生 meta,孤兒 key 從原生讀取即正確;全部非 aeav_only 時不查資料庫。
|
||||||
|
* 原生 meta 已有資料的 key(孤兒期間寫入,比扁平表新)留在原生,不還原。
|
||||||
|
* 還原只影響讀寫路由,不會建立或修改任何資料表。
|
||||||
|
*
|
||||||
|
* @return int 還原的群組數
|
||||||
|
*/
|
||||||
|
public static function restore_orphaned_groups(): int {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$types = array_values(
|
||||||
|
array_filter(
|
||||||
|
TMDO_Entity_Registry::VALID_ENTITY_TYPES,
|
||||||
|
static fn( $type ) => TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $type )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( empty( $types ) ) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta_table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
|
||||||
|
$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT entity_type, group_name, field_definitions FROM `{$meta_table}` WHERE entity_type IN ({$placeholders})", ...$types ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
$orphans = array();
|
||||||
|
foreach ( (array) $rows as $row ) {
|
||||||
|
if ( ! TMDO_Entity_Registry::get_group_fields( $row['entity_type'], $row['group_name'] ) ) {
|
||||||
|
$orphans[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( empty( $orphans ) ) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One SHOW TABLES for all candidates instead of one per orphan.
|
||||||
|
$existing = (array) $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . TMDO_TABLE_PREFIX ) . '%' ) );
|
||||||
|
|
||||||
|
// "entity:key" → native meta has rows. Stable while the key is restored
|
||||||
|
// (aeav_only writes go to the flat table only), so a day-long cache is safe.
|
||||||
|
$native_cache = get_transient( 'tmdo_orphan_native_keys' );
|
||||||
|
$native_cache = is_array( $native_cache ) ? $native_cache : array();
|
||||||
|
$cache_dirty = false;
|
||||||
|
|
||||||
|
$restored = 0;
|
||||||
|
foreach ( $orphans as $row ) {
|
||||||
|
if ( ! in_array( self::get_table_name( $row['entity_type'], $row['group_name'] ), $existing, true ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$fields = json_decode( (string) $row['field_definitions'], true );
|
||||||
|
if ( ! is_array( $fields ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$native_keys = array();
|
||||||
|
foreach ( $fields as $field ) {
|
||||||
|
$key = (string) ( $field['key'] ?? '' );
|
||||||
|
if ( '' === $key ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cache_key = $row['entity_type'] . ':' . $key;
|
||||||
|
if ( ! array_key_exists( $cache_key, $native_cache ) ) {
|
||||||
|
$native_cache[ $cache_key ] = self::native_meta_has_key( $row['entity_type'], $key );
|
||||||
|
$cache_dirty = true;
|
||||||
|
}
|
||||||
|
if ( $native_cache[ $cache_key ] ) {
|
||||||
|
$native_keys[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( TMDO_Entity_Registry::restore_group( $row['entity_type'], $row['group_name'], $fields, $native_keys ) ) {
|
||||||
|
++$restored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $cache_dirty ) {
|
||||||
|
set_transient( 'tmdo_orphan_native_keys', $native_cache, DAY_IN_SECONDS );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the entity's native meta table holds any row for this key.
|
||||||
|
*/
|
||||||
|
private static function native_meta_has_key( string $entity_type, string $meta_key ): bool {
|
||||||
|
global $wpdb;
|
||||||
|
$tables = array(
|
||||||
|
'post' => $wpdb->postmeta,
|
||||||
|
'user' => $wpdb->usermeta,
|
||||||
|
'term' => $wpdb->termmeta,
|
||||||
|
'comment' => $wpdb->commentmeta,
|
||||||
|
);
|
||||||
|
if ( empty( $tables[ $entity_type ] ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (bool) $wpdb->get_var(
|
||||||
|
$wpdb->prepare( "SELECT 1 FROM `{$tables[ $entity_type ]}` WHERE meta_key = %s LIMIT 1", $meta_key )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得已儲存的 Schema hash
|
* 取得已儲存的 Schema hash
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers four user entity groups for membership, activity, profile, and SSO.
|
* Registers the user entity groups for membership, activity and profile.
|
||||||
*
|
*
|
||||||
* Groups → flat tables:
|
* Groups → flat tables:
|
||||||
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
|
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
|
||||||
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
|
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
|
||||||
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
|
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
|
||||||
* sso → wp_wpdo_user_sso (Hub token cache, replaces _tmso_* usermeta)
|
* (sso → wp_wpdo_user_sso is owned by 2meet-data-optimizer-sso-addon)
|
||||||
*
|
*
|
||||||
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
|
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
|
||||||
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
|
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
|
||||||
@@ -43,6 +43,7 @@ final class TMDO_Member_Fields {
|
|||||||
* V2.7.0: Adds four legacy-key groups (core_profile, social, commerce, hp_user)
|
* V2.7.0: Adds four legacy-key groups (core_profile, social, commerce, hp_user)
|
||||||
* to absorb wp_usermeta rows that previously bypassed the entity bridge —
|
* to absorb wp_usermeta rows that previously bypassed the entity bridge —
|
||||||
* driving the wp_users:wp_usermeta ratio from 1:5.5 toward 1:2.5.
|
* driving the wp_users:wp_usermeta ratio from 1:5.5 toward 1:2.5.
|
||||||
|
* (hp_user is now registered by the HivePress AddOn.)
|
||||||
*
|
*
|
||||||
* V2.8.4: Adds admin_prefs group — the 7 default keys WP core writes for
|
* V2.8.4: Adds admin_prefs group — the 7 default keys WP core writes for
|
||||||
* EVERY new user via wp_insert_user (rich_editing, syntax_highlighting,
|
* EVERY new user via wp_insert_user (rich_editing, syntax_highlighting,
|
||||||
@@ -60,11 +61,9 @@ final class TMDO_Member_Fields {
|
|||||||
self::register_membership_group();
|
self::register_membership_group();
|
||||||
self::register_activity_group();
|
self::register_activity_group();
|
||||||
self::register_profile_group();
|
self::register_profile_group();
|
||||||
self::register_sso_group();
|
|
||||||
self::register_core_profile_group();
|
self::register_core_profile_group();
|
||||||
self::register_social_group();
|
self::register_social_group();
|
||||||
self::register_commerce_group();
|
self::register_commerce_group();
|
||||||
self::register_hp_user_group();
|
|
||||||
self::register_admin_prefs_group();
|
self::register_admin_prefs_group();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,70 +212,6 @@ final class TMDO_Member_Fields {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hub/Spoke SSO token cache — replaces _tmso_* usermeta.
|
|
||||||
*
|
|
||||||
* Silent refresh fires every 15 min per user; at 10M users this is a
|
|
||||||
* high-frequency EAV hot-spot. A flat table + object cache hit cuts DB
|
|
||||||
* load 10–50× versus a wp_usermeta EAV scan per refresh.
|
|
||||||
*
|
|
||||||
* last_id_token is NOT stored in plaintext (privacy + volume). Only the
|
|
||||||
* SHA-256 hash is kept for SLO token comparison.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private static function register_sso_group(): void {
|
|
||||||
TMDO_Entity_Registry::register_group(
|
|
||||||
'user',
|
|
||||||
'sso',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'key' => 'hub_global_user_id',
|
|
||||||
'type' => 'text',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Hub global user UUID (2mso_user_mapping.global_user_id bridge key)',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'picture_url',
|
|
||||||
'type' => 'text',
|
|
||||||
'label' => 'Social / SSO profile picture URL',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'last_id_token_hash',
|
|
||||||
'type' => 'text',
|
|
||||||
'label' => 'SHA-256(last_id_token) for SLO comparison — no plaintext stored',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'refresh_token_enc',
|
|
||||||
'type' => 'textarea',
|
|
||||||
'label' => 'Encrypted refresh token (TMSO_Crypto — key-versioned enc_vN:ciphertext)',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'token_expires_at',
|
|
||||||
'type' => 'datetime',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'SSO token expiry (set to past to force re-auth on next request)',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'sso_last_login_at',
|
|
||||||
'type' => 'datetime',
|
|
||||||
'label' => 'Last SSO-initiated login datetime',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'sso_login_count',
|
|
||||||
'type' => 'integer',
|
|
||||||
'default' => 0,
|
|
||||||
'label' => 'SSO login count',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'known_login_ips',
|
|
||||||
'type' => 'textarea',
|
|
||||||
'label' => 'Recent login IPs JSON array (new-device notification) — replaces _tmso_known_ips usermeta',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WP core user fields stored as multi-row EAV in wp_usermeta.
|
* WP core user fields stored as multi-row EAV in wp_usermeta.
|
||||||
*
|
*
|
||||||
@@ -414,34 +349,6 @@ final class TMDO_Member_Fields {
|
|||||||
TMDO_Entity_Registry::register_group( 'user', 'commerce', $fields );
|
TMDO_Entity_Registry::register_group( 'user', 'commerce', $fields );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* HivePress per-user fields (favorites + avatar attachment).
|
|
||||||
*
|
|
||||||
* Hp_favorited_listings is a serialized array of post IDs in legacy storage;
|
|
||||||
* the migration engine safe_unserialize()s it (allowed_classes=false to block
|
|
||||||
* PHP-object injection) then the json type encodes back to a JSON array column.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private static function register_hp_user_group(): void {
|
|
||||||
TMDO_Entity_Registry::register_group(
|
|
||||||
'user',
|
|
||||||
'hp_user',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'key' => 'hp_favorited_listings',
|
|
||||||
'type' => 'json',
|
|
||||||
'label' => 'HivePress favorited listing IDs (array)',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_image',
|
|
||||||
'type' => 'text',
|
|
||||||
'label' => 'HivePress avatar attachment ID',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WP-core admin pref defaults — written by `wp_insert_user()` for EVERY
|
* WP-core admin pref defaults — written by `wp_insert_user()` for EVERY
|
||||||
* new user regardless of role. Without registering these, a fresh user
|
* new user regardless of role. Without registering these, a fresh user
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* Post entity field registration for WP Data Optimizer (v2.9.1).
|
* Post entity field registration for WP Data Optimizer (v2.9.1).
|
||||||
*
|
*
|
||||||
* Registers seven post entity groups designed to absorb the bulk of
|
* Registers four post entity groups designed to absorb the bulk of
|
||||||
* wp_postmeta rows that previously bypassed any flat-table strategy.
|
* wp_postmeta rows that previously bypassed any flat-table strategy.
|
||||||
* All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV.
|
* All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV.
|
||||||
*
|
*
|
||||||
@@ -10,20 +10,20 @@
|
|||||||
* wp_core → wp_wpdo_post_wp_core (cross post_type)
|
* wp_core → wp_wpdo_post_wp_core (cross post_type)
|
||||||
* attachment → wp_wpdo_post_attachment (attachment)
|
* attachment → wp_wpdo_post_attachment (attachment)
|
||||||
* wc_product → wp_wpdo_post_wc_product (product)
|
* wc_product → wp_wpdo_post_wc_product (product)
|
||||||
* hp_listing_core → wp_wpdo_post_hp_listing_core (hp_listing)
|
|
||||||
* hp_request_core → wp_wpdo_post_hp_request_core (hp_request)
|
|
||||||
* hp_vendor_core → wp_wpdo_post_hp_vendor_core (hp_vendor)
|
|
||||||
* nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item)
|
* nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item)
|
||||||
*
|
*
|
||||||
* Pattern mirrors TMDO_Member_Fields::register(). Called from
|
* Pattern mirrors TMDO_Member_Fields::register(). Called from
|
||||||
* TMDO_Core::run() before the wpdo_register_entity_fields action fires;
|
* TMDO_Core::run() before the wpdo_register_entity_fields action fires;
|
||||||
* Schema_Manager materializes the seven flat tables in init:1
|
* Schema_Manager materializes the four flat tables in init:1
|
||||||
* via process_pending_migrations() (idempotent via schema_hash compare).
|
* via process_pending_migrations() (idempotent via schema_hash compare).
|
||||||
*
|
*
|
||||||
* Keys not registered here (HivePress dynamic attrs, WPCS legacy keys,
|
* Keys not registered here (HivePress dynamic attrs, WPCS legacy keys,
|
||||||
* etc.) pass through to wp_postmeta unchanged — same fall-back behavior
|
* etc.) pass through to wp_postmeta unchanged — same fall-back behavior
|
||||||
* as user entity bridge.
|
* as user entity bridge.
|
||||||
*
|
*
|
||||||
|
* The HivePress groups (hp_listing_core / hp_request_core / hp_vendor_core)
|
||||||
|
* are registered by the HivePress AddOn (TMDO_HivePress_Entity_Groups).
|
||||||
|
*
|
||||||
* 🔒 v2.9.x frozen contract: this class must NEVER call register_group()
|
* 🔒 v2.9.x frozen contract: this class must NEVER call register_group()
|
||||||
* with entity_type='user'. User entity registration is owned exclusively
|
* with entity_type='user'. User entity registration is owned exclusively
|
||||||
* by TMDO_Member_Fields.
|
* by TMDO_Member_Fields.
|
||||||
@@ -39,10 +39,50 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers seven post entity groups.
|
* Registers four post entity groups.
|
||||||
*/
|
*/
|
||||||
final class TMDO_Post_Fields {
|
final class TMDO_Post_Fields {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post type of each core group that is not scoped to post types in the
|
||||||
|
* registry (wp_core spans every post type and is absent on purpose).
|
||||||
|
*/
|
||||||
|
private const GROUP_POST_TYPES = array(
|
||||||
|
'attachment' => 'attachment',
|
||||||
|
'wc_product' => 'product',
|
||||||
|
'nav_menu_item' => 'nav_menu_item',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary post type of a post group: the registry scope for scoped groups
|
||||||
|
* (e.g. the HivePress AddOn's), the core mapping otherwise.
|
||||||
|
*
|
||||||
|
* @param string $group Entity group name.
|
||||||
|
* @return string|null Null for cross-post-type groups such as wp_core.
|
||||||
|
*/
|
||||||
|
public static function post_type_for_group( string $group ): ?string {
|
||||||
|
$scoped = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_group_post_types( 'post', $group ) : array();
|
||||||
|
return $scoped[0] ?? ( self::GROUP_POST_TYPES[ $group ] ?? null );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post groups dedicated to a post type (wp_core excluded).
|
||||||
|
*
|
||||||
|
* @param string $post_type Post type slug.
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function groups_for_post_type( string $post_type ): array {
|
||||||
|
$groups = array_keys( self::GROUP_POST_TYPES, $post_type, true );
|
||||||
|
if ( class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||||
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||||||
|
if ( in_array( $post_type, TMDO_Entity_Registry::get_group_post_types( 'post', $group ), true ) ) {
|
||||||
|
$groups[] = $group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values( array_unique( $groups ) );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook into WPDO entity field registration.
|
* Hook into WPDO entity field registration.
|
||||||
*
|
*
|
||||||
@@ -65,9 +105,6 @@ final class TMDO_Post_Fields {
|
|||||||
self::register_wp_core_group();
|
self::register_wp_core_group();
|
||||||
self::register_attachment_group();
|
self::register_attachment_group();
|
||||||
self::register_wc_product_group();
|
self::register_wc_product_group();
|
||||||
self::register_hp_listing_core_group();
|
|
||||||
self::register_hp_request_core_group();
|
|
||||||
self::register_hp_vendor_core_group();
|
|
||||||
self::register_nav_menu_item_group();
|
self::register_nav_menu_item_group();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,173 +301,6 @@ final class TMDO_Post_Fields {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* HivePress listing core meta keys (post_type=hp_listing).
|
|
||||||
* Aligns with the existing wpdo_hot_hp_listing flat table; v2.9.5 will
|
|
||||||
* copy-then-cutover the 230 dev10 rows to this group's table.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private static function register_hp_listing_core_group(): void {
|
|
||||||
TMDO_Entity_Registry::register_group(
|
|
||||||
'post',
|
|
||||||
'hp_listing_core',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'key' => 'hp_price',
|
|
||||||
'type' => 'decimal',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Listing price',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_status',
|
|
||||||
'type' => 'enum',
|
|
||||||
'searchable' => true,
|
|
||||||
'options' => array( 'publish', 'draft', 'pending', 'expired', 'private' ),
|
|
||||||
'label' => 'Listing status',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_featured',
|
|
||||||
'type' => 'integer',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Featured flag (0/1)',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_verified',
|
|
||||||
'type' => 'integer',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Verified flag (0/1)',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_vendor',
|
|
||||||
'type' => 'integer',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Vendor user ID',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_expired_time',
|
|
||||||
'type' => 'integer',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Expiry unix ts',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_featured_time',
|
|
||||||
'type' => 'integer',
|
|
||||||
'label' => 'Featured-until unix ts',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_view_count',
|
|
||||||
'type' => 'integer',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'View counter',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_rating',
|
|
||||||
'type' => 'decimal',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Average rating',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_rating_count',
|
|
||||||
'type' => 'integer',
|
|
||||||
'label' => 'Rating count',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_hourly_rate',
|
|
||||||
'type' => 'decimal',
|
|
||||||
'label' => 'Hourly rate',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HivePress request core meta keys (post_type=hp_request).
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private static function register_hp_request_core_group(): void {
|
|
||||||
TMDO_Entity_Registry::register_group(
|
|
||||||
'post',
|
|
||||||
'hp_request_core',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'key' => 'hp_status',
|
|
||||||
'type' => 'enum',
|
|
||||||
'searchable' => true,
|
|
||||||
'options' => array( 'publish', 'draft', 'pending', 'expired' ),
|
|
||||||
'label' => 'Request status',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_user',
|
|
||||||
'type' => 'integer',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Request author user ID',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_expired_time',
|
|
||||||
'type' => 'integer',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Expiry unix ts',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_budget',
|
|
||||||
'type' => 'decimal',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Budget amount',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_view_count',
|
|
||||||
'type' => 'integer',
|
|
||||||
'label' => 'View counter',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HivePress vendor core meta keys (post_type=hp_vendor).
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private static function register_hp_vendor_core_group(): void {
|
|
||||||
TMDO_Entity_Registry::register_group(
|
|
||||||
'post',
|
|
||||||
'hp_vendor_core',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'key' => 'hp_user',
|
|
||||||
'type' => 'integer',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Vendor user ID (linked WP user)',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_verified',
|
|
||||||
'type' => 'integer',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Verified flag (0/1)',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_hourly_rate',
|
|
||||||
'type' => 'decimal',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Hourly rate',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_rating_count',
|
|
||||||
'type' => 'integer',
|
|
||||||
'label' => 'Rating count',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'hp_rating',
|
|
||||||
'type' => 'decimal',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Average rating',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nav menu item meta keys (post_type=nav_menu_item).
|
* Nav menu item meta keys (post_type=nav_menu_item).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -109,12 +109,12 @@ final class TMDO_Term_Comment_Garbage_Filter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check the admin toggle. Defaults to enabled.
|
* Check the admin toggle. Defaults to disabled.
|
||||||
*
|
*
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function is_enabled(): bool {
|
public static function is_enabled(): bool {
|
||||||
return (bool) get_option( self::OPT_ENABLED, '1' );
|
return (bool) get_option( self::OPT_ENABLED, '0' );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -95,12 +95,13 @@ final class TMDO_Term_Comment_Misc_Bucket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check the admin toggle. Defaults to enabled.
|
* Check the admin toggle. Defaults to disabled; sites that already hold misc
|
||||||
|
* rows keep it on via TMDO_Installer::maybe_preserve_misc_bucket_default().
|
||||||
*
|
*
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function is_enabled(): bool {
|
public static function is_enabled(): bool {
|
||||||
return (bool) get_option( self::OPT_ENABLED, '1' );
|
return (bool) get_option( self::OPT_ENABLED, '0' );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* TMDO_Native_Restorer — write flat-table values back to native meta.
|
||||||
|
*
|
||||||
|
* The only way out of aeav_only: in that mode the flat tables are the sole
|
||||||
|
* copy of every managed key, and demoting the entity (or uninstalling) would
|
||||||
|
* leave reads on native meta that no longer has the data.
|
||||||
|
*
|
||||||
|
* Rule: afterwards native meta returns what reads return today.
|
||||||
|
* - A key is written from the group that currently owns it for that entity
|
||||||
|
* (TMDO_Entity_Registry::get_field(), by post type for posts), so stale
|
||||||
|
* copies in other group tables never win.
|
||||||
|
* - Non-NULL values are stored the way WP stores them (maybe_serialize of
|
||||||
|
* the value Type_Caster returns); a NULL value deletes the native row.
|
||||||
|
* - Entities with no row in the owning group's table are not touched:
|
||||||
|
* leftover native rows for them are outside the flat data.
|
||||||
|
*
|
||||||
|
* Batched by entity id with a cursor option so an interrupted run resumes; a
|
||||||
|
* final pass re-copies rows updated since the first run started. With
|
||||||
|
* $demote the entity then moves to dual_write, which keeps both copies in
|
||||||
|
* step from then on.
|
||||||
|
*
|
||||||
|
* A single group can be written back on its own ($group) and then retired:
|
||||||
|
* retire() forgets its registry_meta row and keeps the flat table under a
|
||||||
|
* `_retired_<Ymd>` name, so orphan restore never routes its keys again.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- table/column names come from the entity registry / adapter; placeholders are built for spread params.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies flat-table values of one entity type back into its native meta table.
|
||||||
|
*/
|
||||||
|
final class TMDO_Native_Restorer {
|
||||||
|
|
||||||
|
/** Option: "entity/group" → last entity id written. */
|
||||||
|
public const CURSOR_OPTION = 'tmdo_restore_native_cursor';
|
||||||
|
|
||||||
|
/** Option: entity → DB time its first (possibly interrupted) run started. */
|
||||||
|
public const STARTED_OPTION = 'tmdo_restore_native_started';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore one entity type (or, with $dry_run, count the rows involved).
|
||||||
|
*
|
||||||
|
* @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
|
||||||
|
* @param bool $dry_run Count only; write nothing.
|
||||||
|
* @param int $batch Rows per batch.
|
||||||
|
* @param bool $demote After a complete run, switch the entity to dual_write.
|
||||||
|
* @param string $group Only this group ('' = every group).
|
||||||
|
* @return array{groups: array<string, array{rows:int, written:int}>, demoted: bool}
|
||||||
|
*/
|
||||||
|
public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '' ): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$report = array(
|
||||||
|
'groups' => array(),
|
||||||
|
'demoted' => false,
|
||||||
|
);
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||||
|
if ( ! $adapter ) {
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
$batch = max( 1, $batch );
|
||||||
|
|
||||||
|
$started = get_option( self::STARTED_OPTION, array() );
|
||||||
|
$started = is_array( $started ) ? $started : array();
|
||||||
|
if ( ! $dry_run && ! isset( $started[ $entity_type ] ) ) {
|
||||||
|
$started[ $entity_type ] = (string) $wpdb->get_var( 'SELECT NOW()' );
|
||||||
|
update_option( self::STARTED_OPTION, $started, false );
|
||||||
|
}
|
||||||
|
$cursor = get_option( self::CURSOR_OPTION, array() );
|
||||||
|
$cursor = is_array( $cursor ) ? $cursor : array();
|
||||||
|
|
||||||
|
$tables = self::group_tables( $entity_type );
|
||||||
|
if ( '' !== $group ) {
|
||||||
|
$tables = array_intersect_key( $tables, array( $group => true ) );
|
||||||
|
}
|
||||||
|
foreach ( $tables as $group => $table ) {
|
||||||
|
$entry = array(
|
||||||
|
'rows' => 0,
|
||||||
|
'written' => 0,
|
||||||
|
);
|
||||||
|
$key = "{$entity_type}/{$group}";
|
||||||
|
$last = $dry_run ? 0 : (int) ( $cursor[ $key ] ?? 0 );
|
||||||
|
do {
|
||||||
|
$rows = self::fetch( $entity_type, $table, $id_col, "t.`{$id_col}` > %d ORDER BY t.`{$id_col}` LIMIT %d", array( $last, $batch ) );
|
||||||
|
$fetched = count( $rows );
|
||||||
|
$entry['rows'] += $fetched;
|
||||||
|
if ( $fetched ) {
|
||||||
|
$last = (int) end( $rows )[ $id_col ];
|
||||||
|
if ( ! $dry_run ) {
|
||||||
|
$entry['written'] += self::write_rows( $entity_type, $group, $adapter, $rows );
|
||||||
|
$cursor[ $key ] = $last;
|
||||||
|
update_option( self::CURSOR_OPTION, $cursor, false );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while ( $fetched === $batch );
|
||||||
|
|
||||||
|
$report['groups'][ $group ] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $dry_run ) {
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch-up: writes that landed in the flat tables after a batch passed their row.
|
||||||
|
foreach ( $tables as $group => $table ) {
|
||||||
|
$rows = self::fetch( $entity_type, $table, $id_col, 't.updated_at >= %s', array( $started[ $entity_type ] ) );
|
||||||
|
self::write_rows( $entity_type, $group, $adapter, $rows );
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( array_keys( $tables ) as $group ) {
|
||||||
|
unset( $cursor[ "{$entity_type}/{$group}" ] );
|
||||||
|
}
|
||||||
|
unset( $started[ $entity_type ] );
|
||||||
|
$cursor ? update_option( self::CURSOR_OPTION, $cursor, false ) : delete_option( self::CURSOR_OPTION );
|
||||||
|
$started ? update_option( self::STARTED_OPTION, $started, false ) : delete_option( self::STARTED_OPTION );
|
||||||
|
|
||||||
|
if ( $demote ) {
|
||||||
|
$report['demoted'] = true === TMDO_Mode_Manager::set( $entity_type, TMDO_Mode_Manager::MODE_DUAL_WRITE );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget a group that no plugin registers any more, once native meta holds
|
||||||
|
* every non-NULL value of its flat table (run() with $group first).
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return array{status:string, mismatches:int, archived_table:string}
|
||||||
|
* status: retired | registered (a plugin still registers it) |
|
||||||
|
* missing (no registry_meta row or flat table) | mismatch (native differs).
|
||||||
|
*/
|
||||||
|
public static function retire( string $entity_type, string $group ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$result = array(
|
||||||
|
'status' => 'missing',
|
||||||
|
'mismatches' => 0,
|
||||||
|
'archived_table' => '',
|
||||||
|
);
|
||||||
|
$restored = array_map( static fn( $r ) => $r['entity_type'] . '/' . $r['group'], TMDO_Entity_Registry::get_restored_groups() );
|
||||||
|
if ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) && ! in_array( "{$entity_type}/{$group}", $restored, true ) ) {
|
||||||
|
$result['status'] = 'registered';
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||||
|
$meta_table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||||
|
$defs = $wpdb->get_var( $wpdb->prepare( "SELECT field_definitions FROM `{$meta_table}` WHERE entity_type = %s AND group_name = %s", $entity_type, $group ) );
|
||||||
|
$fields = json_decode( (string) $defs, true );
|
||||||
|
if ( ! $adapter || ! is_array( $fields ) || null === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$native_table = $adapter->get_native_meta_table();
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
foreach ( (array) $wpdb->get_results( "SELECT * FROM `{$table}`", ARRAY_A ) as $row ) {
|
||||||
|
foreach ( $fields as $field ) {
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( (string) ( $field['key'] ?? '' ) );
|
||||||
|
if ( '' === $col || ! isset( $row[ $col ] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$want = (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) );
|
||||||
|
// get_results(), not get_col(): wpdb reads an empty string as null.
|
||||||
|
$native = array_column( (array) $wpdb->get_results( $wpdb->prepare( "SELECT meta_value FROM `{$native_table}` WHERE `{$id_col}` = %d AND meta_key = %s", (int) $row[ $id_col ], $field['key'] ), ARRAY_A ), 'meta_value' );
|
||||||
|
if ( array( $want ) !== $native ) {
|
||||||
|
++$result['mismatches'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( $result['mismatches'] ) {
|
||||||
|
$result['status'] = 'mismatch';
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$archived = $table . '_retired_' . gmdate( 'Ymd' );
|
||||||
|
$wpdb->query( "RENAME TABLE `{$table}` TO `{$archived}`" );
|
||||||
|
$wpdb->delete(
|
||||||
|
$meta_table,
|
||||||
|
array(
|
||||||
|
'entity_type' => $entity_type,
|
||||||
|
'group_name' => $group,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
delete_transient( 'tmdo_orphan_native_keys' );
|
||||||
|
TMDO_Schema_Manager::flush_table_exists_cache();
|
||||||
|
|
||||||
|
$result['status'] = 'retired';
|
||||||
|
$result['archived_table'] = $archived;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered groups of the entity whose flat table exists.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @return array<string, string> Group → table name.
|
||||||
|
*/
|
||||||
|
private static function group_tables( string $entity_type ): array {
|
||||||
|
$tables = array();
|
||||||
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||||
|
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
$tables[ $group ] = $table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat rows matching $where; post rows carry their post type as __post_type.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $table Flat table.
|
||||||
|
* @param string $id_col Entity id column.
|
||||||
|
* @param string $where WHERE clause with placeholders.
|
||||||
|
* @param array<int, mixed> $params Placeholder values.
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function fetch( string $entity_type, string $table, string $id_col, string $where, array $params ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$select = 'post' === $entity_type
|
||||||
|
? "SELECT t.*, p.post_type AS __post_type FROM `{$table}` t LEFT JOIN `{$wpdb->posts}` p ON p.ID = t.`{$id_col}`"
|
||||||
|
: "SELECT t.* FROM `{$table}` t";
|
||||||
|
|
||||||
|
return (array) $wpdb->get_results( $wpdb->prepare( "{$select} WHERE {$where}", ...$params ), ARRAY_A );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the native meta of the keys this group owns for each row.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group the rows come from.
|
||||||
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||||
|
* @param array<int, array<string, mixed>> $rows Flat rows.
|
||||||
|
* @return int Native values written.
|
||||||
|
*/
|
||||||
|
private static function write_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): int {
|
||||||
|
global $wpdb;
|
||||||
|
if ( empty( $rows ) ) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$meta_table = $adapter->get_native_meta_table();
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
|
||||||
|
$deletes = array();
|
||||||
|
$inserts = array();
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
$id = (int) $row[ $id_col ];
|
||||||
|
$post_type = 'post' === $entity_type ? (string) ( $row['__post_type'] ?? '' ) : '';
|
||||||
|
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
|
||||||
|
$owner = TMDO_Entity_Registry::get_field( $entity_type, $field['key'], $post_type );
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
||||||
|
if ( null === $owner || $owner['group'] !== $group || ! array_key_exists( $col, $row ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$deletes[ $field['key'] ][] = $id;
|
||||||
|
if ( null !== $row[ $col ] ) {
|
||||||
|
$inserts[] = array( $id, $field['key'], (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $deletes as $meta_key => $ids ) {
|
||||||
|
$in = implode( ',', array_map( 'intval', $ids ) );
|
||||||
|
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$meta_table}` WHERE meta_key = %s AND `{$id_col}` IN ({$in})", $meta_key ) );
|
||||||
|
}
|
||||||
|
if ( $inserts ) {
|
||||||
|
$values = implode( ', ', array_fill( 0, count( $inserts ), '(%d, %s, %s)' ) );
|
||||||
|
$wpdb->query( $wpdb->prepare( "INSERT INTO `{$meta_table}` (`{$id_col}`, meta_key, meta_value) VALUES {$values}", ...array_merge( ...$inserts ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( array_unique( array_map( static fn( $row ) => (int) $row[ $id_col ], $rows ) ) as $id ) {
|
||||||
|
wp_cache_delete( $id, "{$entity_type}_meta" );
|
||||||
|
}
|
||||||
|
return count( $inserts );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* TMDO_Post_Group_Reconciler — move post values written under legacy routing.
|
||||||
|
*
|
||||||
|
* Before post_type-scoped routing (tmdo_post_scoped_routing), a meta key
|
||||||
|
* shared by several post groups was read and written through the last group
|
||||||
|
* registered, e.g. a listing's hp_rating lived in wp_wpdo_post_hp_vendor_core.
|
||||||
|
* Scoped lookups read it from the listing's own group instead, so those
|
||||||
|
* values have to be copied there first.
|
||||||
|
*
|
||||||
|
* Rule: after the switch every read must return what it returned before.
|
||||||
|
* - A non-NULL value in the legacy owner's table is what reads served, so
|
||||||
|
* it overwrites the scoped group's column.
|
||||||
|
* - NULL / missing legacy values leave the scoped column alone (it holds
|
||||||
|
* the backfill copy, the only data there is).
|
||||||
|
* - Source rows are left in place; scoped lookups never read them again.
|
||||||
|
*
|
||||||
|
* Batched by entity id with a cursor option so an interrupted run resumes; a
|
||||||
|
* final pass re-copies legacy rows updated since the first run started, then
|
||||||
|
* scoped routing is switched on.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- placeholders are built for spread params; table/column names come from the entity registry via Schema_Manager and are sanitized there.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies legacy-routed post values into their post type's group table.
|
||||||
|
*/
|
||||||
|
final class TMDO_Post_Group_Reconciler {
|
||||||
|
|
||||||
|
/** Option: move id → last entity id copied. */
|
||||||
|
public const CURSOR_OPTION = 'tmdo_post_reconcile_cursor';
|
||||||
|
|
||||||
|
/** Option: DB time the first (possibly interrupted) run started. */
|
||||||
|
public const STARTED_OPTION = 'tmdo_post_reconcile_started';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys of scoped post groups whose legacy owner is another group.
|
||||||
|
*
|
||||||
|
* @return array<int, array{target:string, source:string, key:string, post_types:string[]}>
|
||||||
|
*/
|
||||||
|
public static function plan(): array {
|
||||||
|
$moves = array();
|
||||||
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||||||
|
$post_types = TMDO_Entity_Registry::get_group_post_types( 'post', $group );
|
||||||
|
if ( empty( $post_types ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ( TMDO_Entity_Registry::get_group_keys( 'post', $group ) as $key ) {
|
||||||
|
$legacy = TMDO_Entity_Registry::get_field( 'post', $key );
|
||||||
|
if ( null === $legacy || $legacy['group'] === $group ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$moves[] = array(
|
||||||
|
'target' => $group,
|
||||||
|
'source' => $legacy['group'],
|
||||||
|
'key' => $key,
|
||||||
|
'post_types' => $post_types,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $moves;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the values (or, with $dry_run, count what would be copied).
|
||||||
|
*
|
||||||
|
* @param bool $dry_run Count only; write nothing.
|
||||||
|
* @param int $batch Rows per batch.
|
||||||
|
* @return array{moves: array<int, array<string, mixed>>, enabled: bool}
|
||||||
|
*/
|
||||||
|
public static function run( bool $dry_run = false, int $batch = 500 ): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$report = array(
|
||||||
|
'moves' => array(),
|
||||||
|
'enabled' => false,
|
||||||
|
);
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( 'post' );
|
||||||
|
if ( ! $adapter ) {
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
$batch = max( 1, $batch );
|
||||||
|
|
||||||
|
if ( ! $dry_run && false === get_option( self::STARTED_OPTION ) ) {
|
||||||
|
update_option( self::STARTED_OPTION, (string) $wpdb->get_var( 'SELECT NOW()' ), false );
|
||||||
|
}
|
||||||
|
$cursor = get_option( self::CURSOR_OPTION, array() );
|
||||||
|
$cursor = is_array( $cursor ) ? $cursor : array();
|
||||||
|
|
||||||
|
$moves = self::plan();
|
||||||
|
foreach ( $moves as $move ) {
|
||||||
|
list( $from, $params ) = self::source_clause( $move, $id_col );
|
||||||
|
$entry = array(
|
||||||
|
'target' => $move['target'],
|
||||||
|
'source' => $move['source'],
|
||||||
|
'key' => $move['key'],
|
||||||
|
'copied' => 0,
|
||||||
|
'overwritten' => 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( $dry_run ) {
|
||||||
|
$target = TMDO_Schema_Manager::get_table_name( 'post', $move['target'] );
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] );
|
||||||
|
|
||||||
|
$entry['copied'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) {$from}", ...$params ) );
|
||||||
|
$entry['overwritten'] = (int) $wpdb->get_var(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT COUNT(*) {$from} AND EXISTS ( SELECT 1 FROM `{$target}` t WHERE t.`{$id_col}` = s.`{$id_col}` AND t.`{$col}` IS NOT NULL AND t.`{$col}` <> s.`{$col}` )",
|
||||||
|
...$params
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$report['moves'][] = $entry;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$move_id = "{$move['source']}>{$move['target']}:{$move['key']}";
|
||||||
|
$last = (int) ( $cursor[ $move_id ] ?? 0 );
|
||||||
|
do {
|
||||||
|
$rows = (array) $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT s.`{$id_col}` AS id, s.`" . TMDO_Schema_Manager::sanitize_column_name( $move['key'] ) . "` AS v {$from} AND s.`{$id_col}` > %d ORDER BY s.`{$id_col}` LIMIT %d", ...array_merge( $params, array( $last, $batch ) ) ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
$entry['copied'] += self::copy_rows( $move, $id_col, $rows );
|
||||||
|
$fetched = count( $rows );
|
||||||
|
if ( $fetched ) {
|
||||||
|
$last = (int) end( $rows )['id'];
|
||||||
|
$cursor[ $move_id ] = $last;
|
||||||
|
update_option( self::CURSOR_OPTION, $cursor, false );
|
||||||
|
}
|
||||||
|
} while ( $fetched === $batch );
|
||||||
|
|
||||||
|
$report['moves'][] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $dry_run ) {
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch-up: legacy-routed writes that landed after a batch passed their row.
|
||||||
|
$started = (string) get_option( self::STARTED_OPTION );
|
||||||
|
foreach ( $moves as $move ) {
|
||||||
|
list( $from, $params ) = self::source_clause( $move, $id_col );
|
||||||
|
$rows = (array) $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT s.`{$id_col}` AS id, s.`" . TMDO_Schema_Manager::sanitize_column_name( $move['key'] ) . "` AS v {$from} AND s.updated_at >= %s", ...array_merge( $params, array( $started ) ) ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
self::copy_rows( $move, $id_col, $rows );
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, '1', true );
|
||||||
|
delete_option( self::CURSOR_OPTION );
|
||||||
|
delete_option( self::STARTED_OPTION );
|
||||||
|
$report['enabled'] = true;
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FROM/WHERE for non-NULL legacy values of the move's post types.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $move One entry of plan().
|
||||||
|
* @param string $id_col Entity id column of the flat tables.
|
||||||
|
* @return array{0:string, 1:array<int, mixed>}
|
||||||
|
*/
|
||||||
|
private static function source_clause( array $move, string $id_col ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$source = TMDO_Schema_Manager::get_table_name( 'post', $move['source'] );
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] );
|
||||||
|
$types = implode( ',', array_fill( 0, count( $move['post_types'] ), '%s' ) );
|
||||||
|
|
||||||
|
return array(
|
||||||
|
"FROM `{$source}` s INNER JOIN `{$wpdb->posts}` p ON p.ID = s.`{$id_col}` AND p.post_type IN ({$types}) WHERE s.`{$col}` IS NOT NULL",
|
||||||
|
$move['post_types'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert one column of the target group rows and drop their cached copies.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $move One entry of plan().
|
||||||
|
* @param string $id_col Entity id column of the flat tables.
|
||||||
|
* @param array<int, array<string, mixed>> $rows Source rows: list of { id, v }.
|
||||||
|
* @return int Rows written.
|
||||||
|
*/
|
||||||
|
private static function copy_rows( array $move, string $id_col, array $rows ): int {
|
||||||
|
global $wpdb;
|
||||||
|
if ( empty( $rows ) ) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$target = TMDO_Schema_Manager::get_table_name( 'post', $move['target'] );
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] );
|
||||||
|
|
||||||
|
$values = array();
|
||||||
|
$params = array();
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
$values[] = '(%d, %s)';
|
||||||
|
$params[] = (int) $row['id'];
|
||||||
|
$params[] = (string) $row['v'];
|
||||||
|
}
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"INSERT INTO `{$target}` (`{$id_col}`, `{$col}`) VALUES " . implode( ', ', $values ) . " ON DUPLICATE KEY UPDATE `{$col}` = VALUES(`{$col}`)",
|
||||||
|
...$params
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( class_exists( 'TMDO_Cache_Orchestrator' ) ) {
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
TMDO_Cache_Orchestrator::invalidate( 'post', (int) $row['id'], $move['target'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count( $rows );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -546,8 +546,8 @@ final class TMDO_Post_Migration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map group name to the post_type it targets. The mapping is canonical
|
* Map group name to the post_type it targets (see
|
||||||
* to v2.9.1 group definitions in TMDO_Post_Fields.
|
* TMDO_Post_Fields::post_type_for_group()).
|
||||||
*
|
*
|
||||||
* Returns null for cross-post_type groups (e.g. 'wp_core').
|
* Returns null for cross-post_type groups (e.g. 'wp_core').
|
||||||
*
|
*
|
||||||
@@ -555,23 +555,7 @@ final class TMDO_Post_Migration {
|
|||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
private static function group_post_type( string $group ): ?string {
|
private static function group_post_type( string $group ): ?string {
|
||||||
switch ( $group ) {
|
return TMDO_Post_Fields::post_type_for_group( $group );
|
||||||
case 'attachment':
|
|
||||||
return 'attachment';
|
|
||||||
case 'wc_product':
|
|
||||||
return 'product';
|
|
||||||
case 'hp_listing_core':
|
|
||||||
return 'hp_listing';
|
|
||||||
case 'hp_request_core':
|
|
||||||
return 'hp_request';
|
|
||||||
case 'hp_vendor_core':
|
|
||||||
return 'hp_vendor';
|
|
||||||
case 'nav_menu_item':
|
|
||||||
return 'nav_menu_item';
|
|
||||||
case 'wp_core':
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,18 @@
|
|||||||
* 3. posts_where — append WHERE conditions targeting flat columns
|
* 3. posts_where — append WHERE conditions targeting flat columns
|
||||||
* 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication
|
* 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication
|
||||||
*
|
*
|
||||||
|
* Status / date on the flat table (O-1): when the group's post_status /
|
||||||
|
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
|
||||||
|
* a single-post-type search with explicit statuses and date ordering
|
||||||
|
* INNER JOINs the table, filters the copies, orders by the copied
|
||||||
|
* post_date and skips GROUP BY (one row per post), so MySQL can walk
|
||||||
|
* `idx_post_status_date` and stop at the LIMIT.
|
||||||
|
*
|
||||||
|
* Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every
|
||||||
|
* match inside the page query); found_posts_query answers from a COUNT of the
|
||||||
|
* same query, kept in the object cache until posts' last_changed moves (core
|
||||||
|
* post saves, and flat writes via TMDO_Hook_Bus).
|
||||||
|
*
|
||||||
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
|
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
|
||||||
* only entity_type='post'.
|
* only entity_type='post'.
|
||||||
*
|
*
|
||||||
@@ -43,22 +55,21 @@ class TMDO_Post_Query_Router {
|
|||||||
/** Query var key for capturing routed clauses (consumed by posts_join/where). */
|
/** Query var key for capturing routed clauses (consumed by posts_join/where). */
|
||||||
private const QUERY_VAR = 'wpdo_post_clauses';
|
private const QUERY_VAR = 'wpdo_post_clauses';
|
||||||
|
|
||||||
|
/** Query var holding the status / date copy route, when one applies. */
|
||||||
|
private const COPY_VAR = 'wpdo_post_copy_route';
|
||||||
|
|
||||||
|
/** Query var holding the COUNT for found_posts_query. */
|
||||||
|
private const COUNT_VAR = 'wpdo_post_count_sql';
|
||||||
|
|
||||||
|
/** Object cache group for found-rows counts. */
|
||||||
|
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map post_type → entity group (mirror of TMDO_Post_Migration::group_post_type
|
* Per-request answer to "are this group's copies usable?".
|
||||||
* but inverted; a post_type may map to multiple groups eventually, currently
|
|
||||||
* we route to the most-specific *_core group plus the cross-cutting wp_core
|
|
||||||
* group when the key is in wp_core).
|
|
||||||
*
|
*
|
||||||
* @var array<string,string>
|
* @var array<string, bool>
|
||||||
*/
|
*/
|
||||||
private const POST_TYPE_GROUP_MAP = array(
|
private static array $copies_usable = array();
|
||||||
'product' => 'wc_product',
|
|
||||||
'hp_listing' => 'hp_listing_core',
|
|
||||||
'hp_request' => 'hp_request_core',
|
|
||||||
'hp_vendor' => 'hp_vendor_core',
|
|
||||||
'attachment' => 'attachment',
|
|
||||||
'nav_menu_item' => 'nav_menu_item',
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all WP_Query hooks.
|
* Register all WP_Query hooks.
|
||||||
@@ -70,6 +81,9 @@ class TMDO_Post_Query_Router {
|
|||||||
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
|
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
|
||||||
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
|
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
|
||||||
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
|
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
|
||||||
|
add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
|
||||||
|
add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 );
|
||||||
|
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,15 +124,14 @@ class TMDO_Post_Query_Router {
|
|||||||
|
|
||||||
$matched = false;
|
$matched = false;
|
||||||
foreach ( $post_types as $pt ) {
|
foreach ( $post_types as $pt ) {
|
||||||
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'] );
|
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], (string) $pt );
|
||||||
if ( ! $field ) {
|
if ( ! $field ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Confirm the field's group is appropriate for this post_type:
|
// Confirm the field's group is appropriate for this post_type:
|
||||||
// either cross-cutting wp_core (any) or the post_type-specific group.
|
// either cross-cutting wp_core (any) or the post_type-specific group.
|
||||||
$group = $field['group'] ?? '';
|
$group = $field['group'] ?? '';
|
||||||
$expected_grp = self::POST_TYPE_GROUP_MAP[ $pt ] ?? null;
|
$is_appropriate = ( 'wp_core' === $group ) || in_array( $group, TMDO_Post_Fields::groups_for_post_type( (string) $pt ), true );
|
||||||
$is_appropriate = ( 'wp_core' === $group ) || ( null !== $expected_grp && $expected_grp === $group );
|
|
||||||
if ( ! $is_appropriate ) {
|
if ( ! $is_appropriate ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -152,6 +165,11 @@ class TMDO_Post_Query_Router {
|
|||||||
|
|
||||||
$query->set( 'meta_query', $remaining );
|
$query->set( 'meta_query', $remaining );
|
||||||
$query->set( self::QUERY_VAR, $routed );
|
$query->set( self::QUERY_VAR, $routed );
|
||||||
|
|
||||||
|
$copy = self::copy_route( $query, $routed );
|
||||||
|
if ( $copy ) {
|
||||||
|
$query->set( self::COPY_VAR, $copy );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,7 +203,8 @@ class TMDO_Post_Query_Router {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$join .= " LEFT JOIN `{$table}` AS `{$alias}` "
|
$type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT';
|
||||||
|
$join .= " {$type} JOIN `{$table}` AS `{$alias}` "
|
||||||
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,9 +238,31 @@ class TMDO_Post_Query_Router {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$copy = $query->get( self::COPY_VAR );
|
||||||
|
if ( ! empty( $copy ) ) {
|
||||||
|
$alias = self::table_alias( $copy['post_type'], $copy['group'] );
|
||||||
|
$where .= self::build_condition( "`{$alias}`.`post_status`", 'IN', $copy['statuses'] );
|
||||||
|
}
|
||||||
|
|
||||||
return $where;
|
return $where;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order by the flat table's post_date copy when the copy route applies.
|
||||||
|
*
|
||||||
|
* @param string|null $orderby Current ORDER BY SQL.
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @return string Modified ORDER BY SQL.
|
||||||
|
*/
|
||||||
|
public function posts_orderby( ?string $orderby, \WP_Query $query ): string {
|
||||||
|
$orderby = (string) ( $orderby ?? '' );
|
||||||
|
$copy = $query->get( self::COPY_VAR );
|
||||||
|
if ( empty( $copy ) ) {
|
||||||
|
return $orderby;
|
||||||
|
}
|
||||||
|
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
|
* Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
|
||||||
*
|
*
|
||||||
@@ -232,7 +273,7 @@ class TMDO_Post_Query_Router {
|
|||||||
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
|
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
|
||||||
$groupby = (string) ( $groupby ?? '' );
|
$groupby = (string) ( $groupby ?? '' );
|
||||||
$routed = $query->get( self::QUERY_VAR );
|
$routed = $query->get( self::QUERY_VAR );
|
||||||
if ( empty( $routed ) || ! is_array( $routed ) ) {
|
if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
|
||||||
return $groupby;
|
return $groupby;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +285,46 @@ class TMDO_Post_Query_Router {
|
|||||||
return $groupby;
|
return $groupby;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop SQL_CALC_FOUND_ROWS from a routed search; keep its COUNT for later.
|
||||||
|
*
|
||||||
|
* @param string|null $request Full SELECT.
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function posts_request( ?string $request, \WP_Query $query ): string {
|
||||||
|
$request = (string) ( $request ?? '' );
|
||||||
|
if ( empty( $query->get( self::QUERY_VAR ) ) || false === strpos( $request, 'SQL_CALC_FOUND_ROWS' ) ) {
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
$request = preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
|
||||||
|
$matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
|
||||||
|
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer found rows from the cached COUNT of a routed search.
|
||||||
|
*
|
||||||
|
* @param string|null $sql Default 'SELECT FOUND_ROWS()'.
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function found_posts_query( ?string $sql, \WP_Query $query ): string {
|
||||||
|
$count_sql = $query->get( self::COUNT_VAR );
|
||||||
|
if ( empty( $count_sql ) ) {
|
||||||
|
return (string) ( $sql ?? '' );
|
||||||
|
}
|
||||||
|
$key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' );
|
||||||
|
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
|
||||||
|
if ( false === $total ) {
|
||||||
|
global $wpdb;
|
||||||
|
$total = (int) $wpdb->get_var( $count_sql );
|
||||||
|
wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS );
|
||||||
|
}
|
||||||
|
return 'SELECT ' . (int) $total;
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Helpers
|
// Helpers
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -260,6 +341,76 @@ class TMDO_Post_Query_Router {
|
|||||||
return TMDO_Mode_Manager::reads_from_flat( 'post' );
|
return TMDO_Mode_Manager::reads_from_flat( 'post' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget per-request copy checks (tests, or after `post-status-sync`).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function flush_copy_cache(): void {
|
||||||
|
self::$copies_usable = array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status / date copy route for a routed query, or [] when it does not apply.
|
||||||
|
*
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @param array<string, array<int, array<string, mixed>>> $routed Routed clauses keyed by post type.
|
||||||
|
* @return array{post_type:string, group:string, statuses:string[], order:string}|array{}
|
||||||
|
*/
|
||||||
|
private static function copy_route( \WP_Query $query, array $routed ): array {
|
||||||
|
if ( 1 !== count( $routed ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$post_type = (string) array_key_first( $routed );
|
||||||
|
$groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) );
|
||||||
|
if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$statuses = $query->get( 'post_status' );
|
||||||
|
$statuses = array_values( array_filter( array_map( 'sanitize_key', is_array( $statuses ) ? $statuses : explode( ',', (string) $statuses ) ) ) );
|
||||||
|
if ( empty( $statuses ) || in_array( 'any', $statuses, true ) || ! self::copies_usable( $groups[0] ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'post_type' => $post_type,
|
||||||
|
'group' => $groups[0],
|
||||||
|
'statuses' => $statuses,
|
||||||
|
'order' => 'ASC' === strtoupper( (string) $query->get( 'order' ) ) ? 'ASC' : 'DESC',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the copy route applies to this post type's join.
|
||||||
|
*
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @param string $post_type Post type slug.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function copy_route_for( \WP_Query $query, string $post_type ): bool {
|
||||||
|
$copy = $query->get( self::COPY_VAR );
|
||||||
|
return ! empty( $copy ) && $copy['post_type'] === $post_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies are marked ready and no row lacks them (bulk writes that bypass
|
||||||
|
* the Hook Bus leave NULLs until `wp tmdo post-status-sync` runs again).
|
||||||
|
* One index probe per group per request.
|
||||||
|
*
|
||||||
|
* @param string $group Entity group name.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function copies_usable( string $group ): bool {
|
||||||
|
if ( ! isset( self::$copies_usable[ $group ] ) ) {
|
||||||
|
global $wpdb;
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
self::$copies_usable[ $group ] = class_exists( 'TMDO_Post_Status_Sync' )
|
||||||
|
&& 'ready' === TMDO_Post_Status_Sync::state( $group )
|
||||||
|
&& TMDO_Schema_Manager::table_exists( $table )
|
||||||
|
&& null === $wpdb->get_var( "SELECT 1 FROM `{$table}` WHERE `post_status` IS NULL LIMIT 1" );
|
||||||
|
}
|
||||||
|
return self::$copies_usable[ $group ];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a deterministic JOIN alias for a (post_type, group) pair.
|
* Generate a deterministic JOIN alias for a (post_type, group) pair.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ class TMDO_Snapshot_Manager {
|
|||||||
return array();
|
return array();
|
||||||
}
|
}
|
||||||
$out = array();
|
$out = array();
|
||||||
$modules = array_merge( TMDO_Feature_Flags::HPCT_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
|
$modules = array_merge( TMDO_Feature_Flags::ADDON_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
|
||||||
foreach ( $modules as $m ) {
|
foreach ( $modules as $m ) {
|
||||||
$out[ $m ] = TMDO_Feature_Flags::get( $m );
|
$out[ $m ] = TMDO_Feature_Flags::get( $m );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,12 +122,14 @@ if ( ! trait_exists( 'TMDO_Anti_EAV_Aware' ) ) {
|
|||||||
* @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
|
* @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
|
||||||
* @param string $group_name Logical group name (e.g. 'tmos_vendor', 'tmos_audience').
|
* @param string $group_name Logical group name (e.g. 'tmos_vendor', 'tmos_audience').
|
||||||
* @param array<int,array<string,mixed>> $fields Field definitions.
|
* @param array<int,array<string,mixed>> $fields Field definitions.
|
||||||
|
* @param array<string,mixed> $args Optional: post_types (string[]) scopes a post group
|
||||||
|
* to those post types (see TMDO_Entity_Registry::register_group()).
|
||||||
*/
|
*/
|
||||||
protected function register_entity_fields( string $entity_type, string $group_name, array $fields ): bool {
|
protected function register_entity_fields( string $entity_type, string $group_name, array $fields, array $args = array() ): bool {
|
||||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return TMDO_Entity_Registry::register_group( $entity_type, $group_name, $fields );
|
return TMDO_Entity_Registry::register_group( $entity_type, $group_name, $fields, $args );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Read/write convenience wrappers ──────────────────────────────────────
|
// ── Read/write convenience wrappers ──────────────────────────────────────
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ final class TMDO_Options_Manager {
|
|||||||
'wpb_backup_options',
|
'wpb_backup_options',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁止重導向的選項名稱清單
|
||||||
|
*
|
||||||
|
* 這些選項會被「WordPress 載入之前」執行的元件以原生 SQL 直查 wp_options
|
||||||
|
* (NinjaFirewall Full WAF 走 auto_prepend_file,以 mysqli 讀取 nfw_options
|
||||||
|
* 與 nfw_rules)。一旦被 register_settings_group() 重導向至專屬設定表,
|
||||||
|
* 該元件會讀不到設定而靜默停止運作 —— 不報錯、不寫 log。
|
||||||
|
*
|
||||||
|
* 註:autoload 最佳化不受此限,因為那些元件的 SELECT 不看 autoload 欄位。
|
||||||
|
*/
|
||||||
|
private const PROTECTED_OPTIONS = array(
|
||||||
|
'nfw_options',
|
||||||
|
'nfw_rules',
|
||||||
|
'nfw_checked',
|
||||||
|
);
|
||||||
|
|
||||||
/** autoload 合計閾值(MB),超過則警告 */
|
/** autoload 合計閾值(MB),超過則警告 */
|
||||||
private const AUTOLOAD_WARNING_THRESHOLD_MB = 1;
|
private const AUTOLOAD_WARNING_THRESHOLD_MB = 1;
|
||||||
|
|
||||||
@@ -202,6 +218,22 @@ final class TMDO_Options_Manager {
|
|||||||
public static function register_settings_group( string $group_name, array $option_keys ): void {
|
public static function register_settings_group( string $group_name, array $option_keys ): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
|
// 受保護的選項不得重導向,否則會讓 WordPress 載入前讀取它的元件靜默失效
|
||||||
|
foreach ( array_intersect( $option_keys, self::PROTECTED_OPTIONS ) as $protected_key ) {
|
||||||
|
_doing_it_wrong(
|
||||||
|
__METHOD__,
|
||||||
|
esc_html( "選項 {$protected_key} 由 WordPress 載入前的元件以原生 SQL 直讀 wp_options,重導向會使其靜默失效,已略過。" ),
|
||||||
|
'1.0.2'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$option_keys = array_diff( $option_keys, self::PROTECTED_OPTIONS );
|
||||||
|
|
||||||
|
// 全部都被擋下就不必建表
|
||||||
|
if ( empty( $option_keys ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'settings_' . sanitize_key( $group_name );
|
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'settings_' . sanitize_key( $group_name );
|
||||||
|
|
||||||
// 建立設定專屬表
|
// 建立設定專屬表
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Scale-benchmark data seeder (dev only — `tests/` is excluded from release packages).
|
||||||
|
*
|
||||||
|
* Seeds a HivePress-shaped dataset into a dedicated `*_bench` database:
|
||||||
|
* vendors (users + hp_vendor posts) and listings (hp_listing posts), stored three
|
||||||
|
* ways so PR-005 can compare them at 100k vendors / 1M listings:
|
||||||
|
* 1. native — wp_posts / wp_postmeta / wp_users / wp_usermeta
|
||||||
|
* 2. entity — wpdo_post_hp_listing_core, wpdo_post_hp_vendor_core, wpdo_user_hp_user
|
||||||
|
* 3. hot — wpdo_hot_hp_listing (created by TMDO_Installer::create_hot_table())
|
||||||
|
*
|
||||||
|
* Values are derived from crc32( seed:index:key ), so every row is reproducible
|
||||||
|
* without sequential RNG state.
|
||||||
|
*
|
||||||
|
* @package TMDO\Tests\Bench
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class TMDO_Bench_Seeder {
|
||||||
|
|
||||||
|
public const MAX_ROWS = 2000000;
|
||||||
|
|
||||||
|
/** 2026-01-01 00:00:00 UTC. */
|
||||||
|
private const BASE_TS = 1767225600;
|
||||||
|
|
||||||
|
private const HOT_COLUMNS = array(
|
||||||
|
'hp_price' => 'decimal(12,2) NOT NULL DEFAULT 0',
|
||||||
|
'hp_featured' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||||
|
'hp_verified' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||||
|
'hp_vendor' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||||
|
'hp_expired_time' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||||
|
);
|
||||||
|
|
||||||
|
private ?mysqli $db;
|
||||||
|
private string $prefix;
|
||||||
|
private int $vendors;
|
||||||
|
private int $listings;
|
||||||
|
private int $seed;
|
||||||
|
private int $batch;
|
||||||
|
|
||||||
|
/** @var array<string, array{cols: string[], rows: string[]}> Pending multi-row INSERTs per table. */
|
||||||
|
private array $buffers = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse to run against anything that is not a dedicated bench database.
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException When the name does not end in `_bench`.
|
||||||
|
*/
|
||||||
|
public static function assert_bench_db( string $db_name ): void {
|
||||||
|
if ( ! preg_match( '/^[A-Za-z0-9_]+_bench$/', $db_name ) ) {
|
||||||
|
throw new InvalidArgumentException( "Refusing to seed '{$db_name}': database name must end in _bench." );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws InvalidArgumentException When a count is out of range.
|
||||||
|
*/
|
||||||
|
public static function assert_count( string $label, int $value ): void {
|
||||||
|
if ( $value < 1 || $value > self::MAX_ROWS ) {
|
||||||
|
throw new InvalidArgumentException( "--{$label} must be between 1 and " . self::MAX_ROWS . ", got {$value}." );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __construct( ?mysqli $db, string $prefix, int $vendors, int $listings, int $seed = 42, int $batch = 2000 ) {
|
||||||
|
$this->db = $db;
|
||||||
|
$this->prefix = $prefix;
|
||||||
|
$this->vendors = $vendors;
|
||||||
|
$this->listings = $listings;
|
||||||
|
$this->seed = $seed;
|
||||||
|
$this->batch = max( 1, $batch );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deterministic generators (pure — unit tested) ───────────────────────
|
||||||
|
|
||||||
|
private function det( int $i, string $key, int $mod ): int {
|
||||||
|
return crc32( "{$this->seed}:{$i}:{$key}" ) % $mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1-based vendor index (1..vendors) that owns listing $i. */
|
||||||
|
public function listing_vendor( int $i ): int {
|
||||||
|
return $this->det( $i, 'vendor', $this->vendors ) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 90% publish, 5% draft, 3% pending, 2% private. */
|
||||||
|
public function listing_status( int $i ): string {
|
||||||
|
$r = $this->det( $i, 'status', 100 );
|
||||||
|
if ( $r < 90 ) {
|
||||||
|
return 'publish';
|
||||||
|
}
|
||||||
|
if ( $r < 95 ) {
|
||||||
|
return 'draft';
|
||||||
|
}
|
||||||
|
return $r < 98 ? 'pending' : 'private';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meta a HivePress listing carries. Keys absent from the array are not stored natively.
|
||||||
|
*
|
||||||
|
* @return array<string, int|string>
|
||||||
|
*/
|
||||||
|
public function listing_meta( int $i, int $vendor_post_id ): array {
|
||||||
|
$featured = $this->det( $i, 'featured', 100 ) < 5 ? 1 : 0;
|
||||||
|
$meta = array(
|
||||||
|
'hp_price' => sprintf( '%d.00', 100 + $this->det( $i, 'price', 99901 ) ),
|
||||||
|
'hp_status' => $this->listing_status( $i ),
|
||||||
|
'hp_featured' => $featured,
|
||||||
|
'hp_verified' => $this->det( $i, 'verified', 100 ) < 30 ? 1 : 0,
|
||||||
|
'hp_vendor' => $vendor_post_id,
|
||||||
|
'hp_expired_time' => self::BASE_TS + $this->det( $i, 'expire', 365 ) * 86400,
|
||||||
|
'hp_view_count' => $this->det( $i, 'views', 5000 ),
|
||||||
|
'hp_rating' => sprintf( '%.2f', 1 + $this->det( $i, 'rating', 401 ) / 100 ),
|
||||||
|
'hp_rating_count' => $this->det( $i, 'rating_count', 200 ),
|
||||||
|
);
|
||||||
|
if ( $featured ) {
|
||||||
|
$meta['hp_featured_time'] = self::BASE_TS + $this->det( $i, 'featured_until', 90 ) * 86400;
|
||||||
|
}
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, int|string> */
|
||||||
|
public function vendor_meta( int $v ): array {
|
||||||
|
return array(
|
||||||
|
'hp_user' => $v,
|
||||||
|
'hp_verified' => $this->det( $v, 'v_verified', 100 ) < 40 ? 1 : 0,
|
||||||
|
'hp_hourly_rate' => sprintf( '%d.00', 20 + $this->det( $v, 'rate', 281 ) ),
|
||||||
|
'hp_rating' => sprintf( '%.2f', 1 + $this->det( $v, 'v_rating', 401 ) / 100 ),
|
||||||
|
'hp_rating_count' => $this->det( $v, 'v_rating_count', 500 ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, int|string> */
|
||||||
|
public function user_meta( int $v ): array {
|
||||||
|
return array(
|
||||||
|
'first_name' => 'Vendor',
|
||||||
|
'last_name' => (string) $v,
|
||||||
|
'nickname' => "vendor_{$v}",
|
||||||
|
'description' => "Bench vendor {$v}",
|
||||||
|
'hp_verified' => $this->det( $v, 'u_verified', 100 ) < 40 ? 1 : 0,
|
||||||
|
'hp_image' => (string) ( 100000 + $v ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Seeding ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop and rebuild every bench table, then load the dataset.
|
||||||
|
*
|
||||||
|
* @return array<string, int> Row counts per table.
|
||||||
|
*/
|
||||||
|
public function run(): array {
|
||||||
|
$this->exec( 'SET SESSION unique_checks = 0' );
|
||||||
|
$this->exec( 'SET SESSION foreign_key_checks = 0' );
|
||||||
|
|
||||||
|
$this->create_core_tables();
|
||||||
|
$this->create_flat_tables();
|
||||||
|
|
||||||
|
$this->seed_vendors();
|
||||||
|
$this->seed_listings();
|
||||||
|
|
||||||
|
$counts = array();
|
||||||
|
foreach ( array_keys( $this->table_names() ) as $table ) {
|
||||||
|
$counts[ $table ] = (int) $this->db->query( "SELECT COUNT(*) FROM `{$table}`" )->fetch_row()[0];
|
||||||
|
}
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, true> Every table this seeder owns. */
|
||||||
|
private function table_names(): array {
|
||||||
|
$p = $this->prefix;
|
||||||
|
return array_fill_keys(
|
||||||
|
array(
|
||||||
|
"{$p}users",
|
||||||
|
"{$p}usermeta",
|
||||||
|
"{$p}posts",
|
||||||
|
"{$p}postmeta",
|
||||||
|
"{$p}wpdo_user_hp_user",
|
||||||
|
"{$p}wpdo_post_hp_vendor_core",
|
||||||
|
"{$p}wpdo_post_hp_listing_core",
|
||||||
|
"{$p}wpdo_hot_hp_listing",
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core tables with the columns and indexes WordPress 6.x uses for these queries.
|
||||||
|
*/
|
||||||
|
private function create_core_tables(): void {
|
||||||
|
$p = $this->prefix;
|
||||||
|
foreach ( array( 'users', 'usermeta', 'posts', 'postmeta' ) as $t ) {
|
||||||
|
$this->exec( "DROP TABLE IF EXISTS `{$p}{$t}`" );
|
||||||
|
}
|
||||||
|
$this->exec(
|
||||||
|
"CREATE TABLE `{$p}users` (
|
||||||
|
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
user_login varchar(60) NOT NULL DEFAULT '',
|
||||||
|
user_nicename varchar(50) NOT NULL DEFAULT '',
|
||||||
|
user_email varchar(100) NOT NULL DEFAULT '',
|
||||||
|
user_registered datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
|
display_name varchar(250) NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (ID),
|
||||||
|
KEY user_login_key (user_login),
|
||||||
|
KEY user_nicename (user_nicename),
|
||||||
|
KEY user_email (user_email)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
||||||
|
);
|
||||||
|
$this->exec(
|
||||||
|
"CREATE TABLE `{$p}usermeta` (
|
||||||
|
umeta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||||
|
meta_key varchar(255) DEFAULT NULL,
|
||||||
|
meta_value longtext,
|
||||||
|
PRIMARY KEY (umeta_id),
|
||||||
|
KEY user_id (user_id),
|
||||||
|
KEY meta_key (meta_key(191))
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
||||||
|
);
|
||||||
|
$this->exec(
|
||||||
|
"CREATE TABLE `{$p}posts` (
|
||||||
|
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
post_author bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||||
|
post_date datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
|
post_title text NOT NULL,
|
||||||
|
post_status varchar(20) NOT NULL DEFAULT 'publish',
|
||||||
|
post_name varchar(200) NOT NULL DEFAULT '',
|
||||||
|
post_parent bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||||
|
post_type varchar(20) NOT NULL DEFAULT 'post',
|
||||||
|
PRIMARY KEY (ID),
|
||||||
|
KEY post_name (post_name(191)),
|
||||||
|
KEY type_status_date (post_type,post_status,post_date,ID),
|
||||||
|
KEY post_parent (post_parent),
|
||||||
|
KEY post_author (post_author)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
||||||
|
);
|
||||||
|
$this->exec(
|
||||||
|
"CREATE TABLE `{$p}postmeta` (
|
||||||
|
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||||
|
meta_key varchar(255) DEFAULT NULL,
|
||||||
|
meta_value longtext,
|
||||||
|
PRIMARY KEY (meta_id),
|
||||||
|
KEY post_id (post_id),
|
||||||
|
KEY meta_key (meta_key(191))
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat tables are created by TMDO's own code so the bench measures the real schema.
|
||||||
|
*/
|
||||||
|
private function create_flat_tables(): void {
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'post', new TMDO_Adapter_Post() );
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
|
||||||
|
TMDO_Post_Fields::register_entity_fields();
|
||||||
|
TMDO_Member_Fields::register();
|
||||||
|
|
||||||
|
// Schema_Manager keeps a schema hash per group in wpdo_registry_meta and skips
|
||||||
|
// creation when it matches, so a rebuilt table needs its hash row cleared too.
|
||||||
|
TMDO_Installer::install_v2_tables();
|
||||||
|
$registry_meta = $this->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
|
||||||
|
|
||||||
|
foreach ( array( array( 'post', 'hp_listing_core' ), array( 'post', 'hp_vendor_core' ), array( 'user', 'hp_user' ) ) as [ $type, $group ] ) {
|
||||||
|
$this->exec( 'DROP TABLE IF EXISTS `' . TMDO_Schema_Manager::get_table_name( $type, $group ) . '`' );
|
||||||
|
$this->exec( "DELETE FROM `{$registry_meta}` WHERE entity_type = '{$type}' AND group_name = '{$group}'" );
|
||||||
|
if ( ! TMDO_Schema_Manager::create_or_upgrade_table( $type, $group, TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) {
|
||||||
|
throw new RuntimeException( "Failed to create entity table {$type}/{$group}." );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->exec( "DROP TABLE IF EXISTS `{$this->prefix}wpdo_hot_hp_listing`" );
|
||||||
|
TMDO_Installer::create_hot_table( 'hp_listing', self::HOT_COLUMNS );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seed_vendors(): void {
|
||||||
|
$p = $this->prefix;
|
||||||
|
for ( $v = 1; $v <= $this->vendors; $v++ ) {
|
||||||
|
$this->push( "{$p}users", array( 'ID', 'user_login', 'user_nicename', 'user_email', 'user_registered', 'display_name' ), array( $v, "vendor_{$v}", "vendor-{$v}", "vendor{$v}@bench.invalid", '2026-01-01 00:00:00', "Vendor {$v}" ) );
|
||||||
|
$user_meta = $this->user_meta( $v );
|
||||||
|
foreach ( $user_meta as $key => $value ) {
|
||||||
|
$this->push( "{$p}usermeta", array( 'user_id', 'meta_key', 'meta_value' ), array( $v, $key, $value ) );
|
||||||
|
}
|
||||||
|
$this->push_entity( 'user', 'hp_user', $v, $user_meta );
|
||||||
|
|
||||||
|
// Vendor post IDs are 1..vendors; listing post IDs follow.
|
||||||
|
$this->push( "{$p}posts", array( 'ID', 'post_author', 'post_date', 'post_title', 'post_status', 'post_name', 'post_parent', 'post_type' ), array( $v, $v, '2026-01-01 00:00:00', "Vendor {$v}", 'publish', "vendor-{$v}", 0, 'hp_vendor' ) );
|
||||||
|
$vendor_meta = $this->vendor_meta( $v );
|
||||||
|
foreach ( $vendor_meta as $key => $value ) {
|
||||||
|
$this->push( "{$p}postmeta", array( 'post_id', 'meta_key', 'meta_value' ), array( $v, $key, $value ) );
|
||||||
|
}
|
||||||
|
$this->push_entity( 'post', 'hp_vendor_core', $v, $vendor_meta );
|
||||||
|
}
|
||||||
|
$this->flush_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seed_listings(): void {
|
||||||
|
$p = $this->prefix;
|
||||||
|
for ( $i = 1; $i <= $this->listings; $i++ ) {
|
||||||
|
$post_id = $this->vendors + $i;
|
||||||
|
$vendor_id = $this->listing_vendor( $i );
|
||||||
|
$meta = $this->listing_meta( $i, $vendor_id );
|
||||||
|
$date = gmdate( 'Y-m-d H:i:s', self::BASE_TS + $this->det( $i, 'date', 365 * 86400 ) );
|
||||||
|
|
||||||
|
$this->push( "{$p}posts", array( 'ID', 'post_author', 'post_date', 'post_title', 'post_status', 'post_name', 'post_parent', 'post_type' ), array( $post_id, $vendor_id, $date, "Listing {$i}", $meta['hp_status'], "listing-{$i}", $vendor_id, 'hp_listing' ) );
|
||||||
|
foreach ( $meta as $key => $value ) {
|
||||||
|
$this->push( "{$p}postmeta", array( 'post_id', 'meta_key', 'meta_value' ), array( $post_id, $key, $value ) );
|
||||||
|
}
|
||||||
|
$this->push_entity( 'post', 'hp_listing_core', $post_id, $meta );
|
||||||
|
|
||||||
|
$hot = array( 'post_id' => $post_id );
|
||||||
|
foreach ( array_keys( self::HOT_COLUMNS ) as $col ) {
|
||||||
|
$hot[ $col ] = $meta[ $col ] ?? 0;
|
||||||
|
}
|
||||||
|
$this->push( "{$p}wpdo_hot_hp_listing", array_keys( $hot ), array_values( $hot ) );
|
||||||
|
}
|
||||||
|
$this->flush_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entity-table row per entity; registry keys without a value are stored as NULL.
|
||||||
|
*
|
||||||
|
* @param array<string, int|string> $meta
|
||||||
|
*/
|
||||||
|
private function push_entity( string $type, string $group, int $entity_id, array $meta ): void {
|
||||||
|
$id_col = TMDO_Entity_Registry::get_adapter( $type )->get_entity_id_column();
|
||||||
|
$cols = array( $id_col );
|
||||||
|
$vals = array( $entity_id );
|
||||||
|
foreach ( TMDO_Entity_Registry::get_group_keys( $type, $group ) as $key ) {
|
||||||
|
$cols[] = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||||
|
$vals[] = $meta[ $key ] ?? null;
|
||||||
|
}
|
||||||
|
$this->push( TMDO_Schema_Manager::get_table_name( $type, $group ), $cols, $vals );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $cols
|
||||||
|
* @param array<int|string|null> $vals
|
||||||
|
*/
|
||||||
|
private function push( string $table, array $cols, array $vals ): void {
|
||||||
|
$escaped = array_map(
|
||||||
|
fn( $v ) => null === $v ? 'NULL' : ( is_int( $v ) ? (string) $v : "'" . $this->db->real_escape_string( (string) $v ) . "'" ),
|
||||||
|
$vals
|
||||||
|
);
|
||||||
|
$this->buffers[ $table ]['cols'] = $cols;
|
||||||
|
$this->buffers[ $table ]['rows'][] = '(' . implode( ',', $escaped ) . ')';
|
||||||
|
if ( count( $this->buffers[ $table ]['rows'] ) >= $this->batch ) {
|
||||||
|
$this->flush( $table );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flush( string $table ): void {
|
||||||
|
if ( empty( $this->buffers[ $table ]['rows'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$cols = '`' . implode( '`,`', $this->buffers[ $table ]['cols'] ) . '`';
|
||||||
|
$this->exec( "INSERT INTO `{$table}` ({$cols}) VALUES " . implode( ',', $this->buffers[ $table ]['rows'] ) );
|
||||||
|
$this->buffers[ $table ]['rows'] = array();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flush_all(): void {
|
||||||
|
foreach ( array_keys( $this->buffers ) as $table ) {
|
||||||
|
$this->flush( $table );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function exec( string $sql ): void {
|
||||||
|
if ( ! $this->db->query( $sql ) ) {
|
||||||
|
throw new RuntimeException( 'Bench seed query failed: ' . $this->db->error . ' — ' . substr( $sql, 0, 200 ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Scale benchmark: native postmeta vs hot table vs entity table.
|
||||||
|
*
|
||||||
|
* Runs against the dataset from tests/bench/seed.php:
|
||||||
|
* TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/run.php [--iterations=20] [--seed=7]
|
||||||
|
*
|
||||||
|
* SQL mirrors what the code under test emits: WP_Query meta_query (INNER JOIN
|
||||||
|
* per clause, CAST for numerics, SQL_CALC_FOUND_ROWS + FOUND_ROWS()),
|
||||||
|
* TMDO_Query_Router (hot) and TMDO_Post_Query_Router (entity, %s-quoted values).
|
||||||
|
* Writes run inside a transaction that is rolled back, so the dataset never changes.
|
||||||
|
*
|
||||||
|
* Output: a Markdown table on stdout (p50 / p95 / max in ms, result rows, index used).
|
||||||
|
*
|
||||||
|
* @package TMDO\Tests\Bench
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if ( 'cli' !== PHP_SAPI ) {
|
||||||
|
exit( 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/class-tmdo-bench-seeder.php';
|
||||||
|
|
||||||
|
const P = 'wp_itest_';
|
||||||
|
|
||||||
|
$opts = getopt( '', array( 'iterations::', 'seed::' ) );
|
||||||
|
$iterations = max( 1, (int) ( $opts['iterations'] ?? 20 ) );
|
||||||
|
$warmup = 3;
|
||||||
|
$db_name = (string) getenv( 'TMDO_TEST_DB_NAME' );
|
||||||
|
|
||||||
|
try {
|
||||||
|
TMDO_Bench_Seeder::assert_bench_db( $db_name );
|
||||||
|
} catch ( InvalidArgumentException $e ) {
|
||||||
|
fwrite( STDERR, $e->getMessage() . PHP_EOL );
|
||||||
|
exit( 2 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = new mysqli(
|
||||||
|
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
|
||||||
|
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
|
||||||
|
(string) getenv( 'TMDO_TEST_DB_PASS' ),
|
||||||
|
$db_name
|
||||||
|
);
|
||||||
|
$db->set_charset( 'utf8mb4' );
|
||||||
|
$db->query( 'SET SESSION max_statement_time = 30' ); // MariaDB: cap runaway native queries.
|
||||||
|
|
||||||
|
$vendors = (int) $db->query( 'SELECT COUNT(*) FROM `' . P . "posts` WHERE post_type = 'hp_vendor'" )->fetch_row()[0];
|
||||||
|
$listings = (int) $db->query( 'SELECT COUNT(*) FROM `' . P . "posts` WHERE post_type = 'hp_listing'" )->fetch_row()[0];
|
||||||
|
if ( 0 === $vendors || 0 === $listings ) {
|
||||||
|
fwrite( STDERR, "No bench data — run tests/bench/seed.php first.\n" );
|
||||||
|
exit( 3 );
|
||||||
|
}
|
||||||
|
|
||||||
|
mt_srand( (int) ( $opts['seed'] ?? 7 ) );
|
||||||
|
|
||||||
|
// ── SQL builders ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** WP_Query shape with one INNER JOIN per meta clause. */
|
||||||
|
function native_search( array $meta_where ): string {
|
||||||
|
$joins = '';
|
||||||
|
$where = array();
|
||||||
|
foreach ( $meta_where as $n => $cond ) {
|
||||||
|
$alias = 0 === $n ? P . 'postmeta' : "mt{$n}";
|
||||||
|
$joins .= ' INNER JOIN `' . P . 'postmeta`' . ( 0 === $n ? '' : " AS {$alias}" ) . " ON ( p.ID = {$alias}.post_id )";
|
||||||
|
$where[] = '( ' . sprintf( $cond, $alias ) . ' )';
|
||||||
|
}
|
||||||
|
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p{$joins} WHERE 1=1 AND ( " . implode( ' AND ', $where )
|
||||||
|
. " ) AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')) GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 0, 20";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TMDO router shape: one LEFT JOIN to a flat table plus flat WHERE clauses. */
|
||||||
|
function flat_search( string $table, string $alias, string $conditions ): string {
|
||||||
|
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p LEFT JOIN `" . P . "{$table}` AS `{$alias}` ON (p.ID = `{$alias}`.`post_id`)"
|
||||||
|
. " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 0, 20";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TMDO router shape with post_status / post_date copies (PR-029): INNER JOIN, no GROUP BY. */
|
||||||
|
function flat_copy_search( string $table, string $alias, string $conditions ): string {
|
||||||
|
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p INNER JOIN `" . P . "{$table}` AS `{$alias}` ON (p.ID = `{$alias}`.`post_id`)"
|
||||||
|
. " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} AND `{$alias}`.`post_status` IN ('publish') ORDER BY `{$alias}`.`post_date` DESC LIMIT 0, 20";
|
||||||
|
}
|
||||||
|
|
||||||
|
function random_ids( int $count, int $offset, int $max ): string {
|
||||||
|
$ids = array();
|
||||||
|
for ( $i = 0; $i < $count; $i++ ) {
|
||||||
|
$ids[] = $offset + mt_rand( 1, $max );
|
||||||
|
}
|
||||||
|
return implode( ',', $ids );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenarios: label => [ variant => fn(): string[] (statements) ] ─────────
|
||||||
|
|
||||||
|
$price = static function (): array {
|
||||||
|
$lo = mt_rand( 100, 80000 );
|
||||||
|
return array( $lo, $lo + 20000 ); // ~20% of the 100..100000 range.
|
||||||
|
};
|
||||||
|
|
||||||
|
$scenarios = array(
|
||||||
|
'1. 價格區間搜尋(約 20%)' => array(
|
||||||
|
'native' => function () use ( $price ) {
|
||||||
|
[ $lo, $hi ] = $price();
|
||||||
|
return array( native_search( array( "%1\$s.meta_key = 'hp_price' AND CAST(%1\$s.meta_value AS DECIMAL(10,2)) BETWEEN '{$lo}' AND '{$hi}'" ) ), 'SELECT FOUND_ROWS()' );
|
||||||
|
},
|
||||||
|
'hot' => function () use ( $price ) {
|
||||||
|
[ $lo, $hi ] = $price();
|
||||||
|
return array( flat_search( 'wpdo_hot_hp_listing', 'h', " AND `h`.`hp_price` BETWEEN {$lo} AND {$hi}" ), 'SELECT FOUND_ROWS()' );
|
||||||
|
},
|
||||||
|
'entity' => function () use ( $price ) {
|
||||||
|
[ $lo, $hi ] = $price();
|
||||||
|
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' );
|
||||||
|
},
|
||||||
|
'entity+copy' => function () use ( $price ) {
|
||||||
|
[ $lo, $hi ] = $price();
|
||||||
|
return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' );
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'2. 價格區間 + verified=1' => array(
|
||||||
|
'native' => function () use ( $price ) {
|
||||||
|
[ $lo, $hi ] = $price();
|
||||||
|
return array(
|
||||||
|
native_search(
|
||||||
|
array(
|
||||||
|
"%1\$s.meta_key = 'hp_price' AND CAST(%1\$s.meta_value AS DECIMAL(10,2)) BETWEEN '{$lo}' AND '{$hi}'",
|
||||||
|
"%1\$s.meta_key = 'hp_verified' AND %1\$s.meta_value = '1'",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'SELECT FOUND_ROWS()',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'hot' => function () use ( $price ) {
|
||||||
|
[ $lo, $hi ] = $price();
|
||||||
|
return array( flat_search( 'wpdo_hot_hp_listing', 'h', " AND `h`.`hp_price` BETWEEN {$lo} AND {$hi} AND `h`.`hp_verified` = 1" ), 'SELECT FOUND_ROWS()' );
|
||||||
|
},
|
||||||
|
'entity' => function () use ( $price ) {
|
||||||
|
[ $lo, $hi ] = $price();
|
||||||
|
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' );
|
||||||
|
},
|
||||||
|
'entity+copy' => function () use ( $price ) {
|
||||||
|
[ $lo, $hi ] = $price();
|
||||||
|
return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' );
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'3. 某 vendor 名下 listing' => array(
|
||||||
|
'post_parent' => fn() => array( 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p WHERE p.post_parent = " . mt_rand( 1, $vendors ) . " AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')) ORDER BY p.post_date DESC LIMIT 0, 20", 'SELECT FOUND_ROWS()' ),
|
||||||
|
'native' => fn() => array( native_search( array( "%1\$s.meta_key = 'hp_vendor' AND %1\$s.meta_value = '" . mt_rand( 1, $vendors ) . "'" ) ), 'SELECT FOUND_ROWS()' ),
|
||||||
|
'hot' => fn() => array( flat_search( 'wpdo_hot_hp_listing', 'h', ' AND `h`.`hp_vendor` = ' . mt_rand( 1, $vendors ) ), 'SELECT FOUND_ROWS()' ),
|
||||||
|
'entity' => fn() => array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_vendor` = '" . mt_rand( 1, $vendors ) . "'" ), 'SELECT FOUND_ROWS()' ),
|
||||||
|
),
|
||||||
|
'4. 單筆 listing 讀全部 meta' => array(
|
||||||
|
'native' => fn() => array( 'SELECT post_id, meta_key, meta_value FROM `' . P . 'postmeta` WHERE post_id IN (' . random_ids( 1, $vendors, $listings ) . ') ORDER BY meta_id ASC' ),
|
||||||
|
'hot' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_hot_hp_listing` WHERE post_id = ' . random_ids( 1, $vendors, $listings ) ),
|
||||||
|
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_post_hp_listing_core` WHERE post_id = ' . random_ids( 1, $vendors, $listings ) ),
|
||||||
|
),
|
||||||
|
'5. 列表頁預載 20 筆 meta' => array(
|
||||||
|
'native' => fn() => array( 'SELECT post_id, meta_key, meta_value FROM `' . P . 'postmeta` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ') ORDER BY meta_id ASC' ),
|
||||||
|
'hot' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_hot_hp_listing` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ')' ),
|
||||||
|
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_post_hp_listing_core` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ')' ),
|
||||||
|
),
|
||||||
|
'6. 單一 vendor 讀使用者 meta' => array(
|
||||||
|
'native' => fn() => array( 'SELECT user_id, meta_key, meta_value FROM `' . P . 'usermeta` WHERE user_id IN (' . mt_rand( 1, $vendors ) . ') ORDER BY umeta_id ASC' ),
|
||||||
|
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_user_hp_user` WHERE user_id = ' . mt_rand( 1, $vendors ) ),
|
||||||
|
),
|
||||||
|
'7. 寫入:更新一筆 listing 價格' => array(
|
||||||
|
'native' => fn() => array( 'UPDATE `' . P . "postmeta` SET meta_value = '" . mt_rand( 100, 100000 ) . ".00' WHERE post_id = " . random_ids( 1, $vendors, $listings ) . " AND meta_key = 'hp_price'" ),
|
||||||
|
'entity' => fn() => array( 'UPDATE `' . P . "wpdo_post_hp_listing_core` SET hp_price = '" . mt_rand( 100, 100000 ) . "' WHERE post_id = " . random_ids( 1, $vendors, $listings ) ),
|
||||||
|
'dual_write' => function () use ( $vendors, $listings ) {
|
||||||
|
$id = random_ids( 1, $vendors, $listings );
|
||||||
|
$v = mt_rand( 100, 100000 );
|
||||||
|
return array(
|
||||||
|
'UPDATE `' . P . "postmeta` SET meta_value = '{$v}.00' WHERE post_id = {$id} AND meta_key = 'hp_price'",
|
||||||
|
'UPDATE `' . P . "wpdo_post_hp_listing_core` SET hp_price = '{$v}' WHERE post_id = {$id}",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Run ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ms: float, rows: int|string, timeout: bool}
|
||||||
|
*/
|
||||||
|
function run_statements( mysqli $db, array $statements ): array {
|
||||||
|
$is_write = str_starts_with( $statements[0], 'UPDATE' );
|
||||||
|
if ( $is_write ) {
|
||||||
|
$db->begin_transaction();
|
||||||
|
}
|
||||||
|
$rows = 0;
|
||||||
|
$timeout = false;
|
||||||
|
$start = hrtime( true );
|
||||||
|
foreach ( $statements as $n => $sql ) {
|
||||||
|
try {
|
||||||
|
$res = $db->query( $sql );
|
||||||
|
} catch ( mysqli_sql_exception $e ) {
|
||||||
|
if ( 1969 !== $e->getCode() ) { // ER_STATEMENT_TIMEOUT.
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
$timeout = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ( $res instanceof mysqli_result ) {
|
||||||
|
$rows = str_starts_with( $sql, 'SELECT FOUND_ROWS' ) ? (int) $res->fetch_row()[0] : ( 0 === $n ? $res->num_rows : $rows );
|
||||||
|
$res->free();
|
||||||
|
} elseif ( $is_write ) {
|
||||||
|
$rows = $db->affected_rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ms = ( hrtime( true ) - $start ) / 1e6;
|
||||||
|
if ( $is_write ) {
|
||||||
|
$db->rollback();
|
||||||
|
}
|
||||||
|
return array( 'ms' => $ms, 'rows' => $rows, 'timeout' => $timeout );
|
||||||
|
}
|
||||||
|
|
||||||
|
function explain_keys( mysqli $db, string $sql ): string {
|
||||||
|
if ( ! str_starts_with( $sql, 'SELECT' ) ) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
$keys = array();
|
||||||
|
$res = $db->query( 'EXPLAIN ' . $sql );
|
||||||
|
while ( $row = $res->fetch_assoc() ) {
|
||||||
|
$keys[] = $row['table'] . ':' . ( $row['key'] ?? 'ALL' );
|
||||||
|
}
|
||||||
|
return implode( ', ', $keys );
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentile( array $sorted, float $p ): float {
|
||||||
|
return $sorted[ (int) min( count( $sorted ) - 1, floor( $p * count( $sorted ) ) ) ];
|
||||||
|
}
|
||||||
|
|
||||||
|
printf( "Dataset: %d vendors, %d listings — %d iterations (+%d warm-up) per variant\n\n", $vendors, $listings, $iterations, $warmup );
|
||||||
|
echo "| 情境 | 形式 | p50 ms | p95 ms | max ms | 結果筆數(中位) | 使用索引 |\n|---|---|---:|---:|---:|---:|---|\n";
|
||||||
|
|
||||||
|
foreach ( $scenarios as $label => $variants ) {
|
||||||
|
foreach ( $variants as $variant => $build ) {
|
||||||
|
$times = array();
|
||||||
|
$rows = array();
|
||||||
|
$timeouts = 0;
|
||||||
|
$keys = explain_keys( $db, $build()[0] );
|
||||||
|
for ( $i = 0; $i < $warmup + $iterations; $i++ ) {
|
||||||
|
$r = run_statements( $db, $build() );
|
||||||
|
if ( $i < $warmup ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$timeouts += (int) $r['timeout'];
|
||||||
|
$times[] = $r['ms'];
|
||||||
|
$rows[] = $r['rows'];
|
||||||
|
}
|
||||||
|
sort( $times );
|
||||||
|
sort( $rows );
|
||||||
|
printf(
|
||||||
|
"| %s | %s | %.2f | %.2f | %.2f | %s | %s |\n",
|
||||||
|
$label,
|
||||||
|
$variant . ( $timeouts ? " ⚠ {$timeouts}× timeout" : '' ),
|
||||||
|
percentile( $times, 0.5 ),
|
||||||
|
percentile( $times, 0.95 ),
|
||||||
|
end( $times ),
|
||||||
|
$rows[ intdiv( count( $rows ), 2 ) ],
|
||||||
|
$keys
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Seed the scale-benchmark dataset.
|
||||||
|
*
|
||||||
|
* Usage (DB credentials come from the same env vars as the integration suite):
|
||||||
|
* TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/seed.php --vendors=100000 --listings=1000000
|
||||||
|
*
|
||||||
|
* Options: --vendors=N (default 1000), --listings=N (default 10000), --seed=N (default 42),
|
||||||
|
* --batch=N rows per INSERT (default 2000).
|
||||||
|
*
|
||||||
|
* Drops and rebuilds its own wp_itest_* tables every run. Refuses any database
|
||||||
|
* whose name does not end in `_bench`.
|
||||||
|
*
|
||||||
|
* @package TMDO\Tests\Bench
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if ( 'cli' !== PHP_SAPI ) {
|
||||||
|
exit( 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/class-tmdo-bench-seeder.php';
|
||||||
|
|
||||||
|
$opts = getopt( '', array( 'vendors::', 'listings::', 'seed::', 'batch::' ) );
|
||||||
|
$vendors = (int) ( $opts['vendors'] ?? 1000 );
|
||||||
|
$listings = (int) ( $opts['listings'] ?? 10000 );
|
||||||
|
$db_name = (string) getenv( 'TMDO_TEST_DB_NAME' );
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Before the bootstrap: it runs CREATE DATABASE on whatever name it is given.
|
||||||
|
TMDO_Bench_Seeder::assert_bench_db( $db_name );
|
||||||
|
TMDO_Bench_Seeder::assert_count( 'vendors', $vendors );
|
||||||
|
TMDO_Bench_Seeder::assert_count( 'listings', $listings );
|
||||||
|
} catch ( InvalidArgumentException $e ) {
|
||||||
|
fwrite( STDERR, $e->getMessage() . PHP_EOL );
|
||||||
|
exit( 2 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema_Manager / Installer require ABSPATH . 'wp-admin/includes/upgrade.php'.
|
||||||
|
define( 'ABSPATH', __DIR__ . '/wp-stub/' );
|
||||||
|
require dirname( __DIR__ ) . '/integration/bootstrap.php';
|
||||||
|
|
||||||
|
$started = microtime( true );
|
||||||
|
$seeder = new TMDO_Bench_Seeder( $_mysqli, $wpdb->prefix, $vendors, $listings, (int) ( $opts['seed'] ?? 42 ), (int) ( $opts['batch'] ?? 2000 ) );
|
||||||
|
$counts = $seeder->run();
|
||||||
|
|
||||||
|
printf( "Seeded %s: %d vendors, %d listings in %.1fs\n", $db_name, $vendors, $listings, microtime( true ) - $started );
|
||||||
|
foreach ( $counts as $table => $rows ) {
|
||||||
|
printf( " %-40s %12d\n", $table, $rows );
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Stand-in for WordPress's wp-admin/includes/upgrade.php during bench seeding.
|
||||||
|
*
|
||||||
|
* TMDO_Schema_Manager and TMDO_Installer require this file for dbDelta();
|
||||||
|
* tests/integration/bootstrap.php already provides dbDelta(), so it is empty.
|
||||||
|
*
|
||||||
|
* @package TMDO\Tests\Bench
|
||||||
|
*/
|
||||||
+19
-2
@@ -230,8 +230,10 @@ if ( ! function_exists( 'esc_sql' ) ) {
|
|||||||
return addslashes( $s );
|
return addslashes( $s );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } }
|
||||||
|
if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } }
|
||||||
if ( ! function_exists( '_doing_it_wrong' ) ) {
|
if ( ! function_exists( '_doing_it_wrong' ) ) {
|
||||||
function _doing_it_wrong( string $fn, string $msg, string $ver ): void {}
|
function _doing_it_wrong( string $fn, string $msg, string $ver ): void { $GLOBALS['_doing_it_wrong_calls'][] = array( $fn, $msg ); }
|
||||||
}
|
}
|
||||||
if ( ! function_exists( 'get_option' ) ) {
|
if ( ! function_exists( 'get_option' ) ) {
|
||||||
function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; }
|
function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; }
|
||||||
@@ -426,6 +428,18 @@ if ( ! function_exists( 'wp_cache_delete' ) ) {
|
|||||||
function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; }
|
function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registered post types: name => publicly viewable (bool).
|
||||||
|
if ( ! function_exists( 'post_type_exists' ) ) {
|
||||||
|
function post_type_exists( string $post_type ): bool {
|
||||||
|
return isset( $GLOBALS['_wp_registered_post_types'][ $post_type ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'is_post_type_viewable' ) ) {
|
||||||
|
function is_post_type_viewable( $post_type ): bool {
|
||||||
|
return ! empty( $GLOBALS['_wp_registered_post_types'][ (string) $post_type ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Stub classes ───────────────────────────────────────────────────────────
|
// ── Stub classes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ( ! class_exists( 'WP_Query' ) ) {
|
if ( ! class_exists( 'WP_Query' ) ) {
|
||||||
@@ -434,7 +448,7 @@ if ( ! class_exists( 'WP_Query' ) ) {
|
|||||||
public int $found_posts = 0;
|
public int $found_posts = 0;
|
||||||
public int $max_num_pages = 0;
|
public int $max_num_pages = 0;
|
||||||
private array $args = [];
|
private array $args = [];
|
||||||
public function __construct( array $args = [] ) { $this->args = $args; }
|
public function __construct( array $args = [] ) { $this->args = $args; $this->posts = $GLOBALS['_wp_query_posts'] ?? []; }
|
||||||
public function get( string $key, $default = '' ) { return $this->args[ $key ] ?? $default; }
|
public function get( string $key, $default = '' ) { return $this->args[ $key ] ?? $default; }
|
||||||
public function set( string $key, $value ): void { $this->args[ $key ] = $value; }
|
public function set( string $key, $value ): void { $this->args[ $key ] = $value; }
|
||||||
}
|
}
|
||||||
@@ -630,6 +644,9 @@ require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-cleanup.php
|
|||||||
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
|
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
|
||||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
|
||||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
|
||||||
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
|
||||||
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
|
||||||
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
|
||||||
|
|
||||||
// Options Manager.
|
// Options Manager.
|
||||||
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
|
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite indexes declared on a group reach its existing flat table.
|
||||||
|
*
|
||||||
|
* The table already exists with data (its schema hash is unchanged), so the
|
||||||
|
* index is added in place rather than through a table rebuild.
|
||||||
|
*/
|
||||||
|
class GroupCompositeIndexTest extends TestCase {
|
||||||
|
|
||||||
|
private const TABLE = 'wp_itest_wpdo_post_ci_core';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, ci_flag BIGINT(20) DEFAULT NULL, ci_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' );
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group(
|
||||||
|
'post',
|
||||||
|
'ci_core',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'ci_flag', 'type' => 'integer' ),
|
||||||
|
array( 'key' => 'ci_price', 'type' => 'decimal' ),
|
||||||
|
),
|
||||||
|
array( 'indexes' => array( 'flag_price' => array( 'ci_flag', 'ci_price' ) ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string[]> index name → ordered columns */
|
||||||
|
private function indexes(): array {
|
||||||
|
global $wpdb;
|
||||||
|
$out = array();
|
||||||
|
foreach ( (array) $wpdb->get_results( 'SHOW INDEX FROM `' . self::TABLE . '`', ARRAY_A ) as $row ) {
|
||||||
|
$out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_missing_composite_index_is_added_to_the_existing_table(): void {
|
||||||
|
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'ci_flag', 'ci_price' ), $this->indexes()['cidx_flag_price'] ?? null );
|
||||||
|
global $wpdb;
|
||||||
|
$this->assertSame( '2', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' ), 'data untouched' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_applied_definition_is_remembered_so_later_requests_skip_the_check(): void {
|
||||||
|
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` DROP INDEX `cidx_flag_price`' );
|
||||||
|
|
||||||
|
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'same definition → no SHOW INDEX / ALTER' );
|
||||||
|
$this->assertArrayHasKey( 'post/ci_core', get_option( TMDO_Schema_Manager::INDEXES_OPTION ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_group_without_indexes_is_left_alone(): void {
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'ci_core', array( array( 'key' => 'ci_flag', 'type' => 'integer' ) ) );
|
||||||
|
|
||||||
|
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'PRIMARY', 'uk_entity' ), array_keys( $this->indexes() ) );
|
||||||
|
$this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,19 @@ class InstallerV2Test extends TestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_install_does_not_enable_entity_bridge(): void {
|
||||||
|
delete_option( 'wpdo_hook_bus_enabled' );
|
||||||
|
delete_option( 'wpdo_bridge_modes' );
|
||||||
|
delete_option( 'wpdo_entity_bridge_autoactivated_v2' );
|
||||||
|
// Skip the crypto step: it queries $wpdb->options, which InstallerCleanupTest leaves pointing at its own prefix.
|
||||||
|
update_option( 'wpdo_crypto_migrated_v2', '1' );
|
||||||
|
|
||||||
|
TMDO_Installer::install();
|
||||||
|
|
||||||
|
$this->assertFalse( get_option( 'wpdo_hook_bus_enabled' ), 'install() must not switch the Hook Bus on' );
|
||||||
|
$this->assertFalse( get_option( 'wpdo_bridge_modes' ), 'install() must not write bridge modes' );
|
||||||
|
}
|
||||||
|
|
||||||
public static function tearDownAfterClass(): void {
|
public static function tearDownAfterClass(): void {
|
||||||
// Leave v2 tables in place for subsequent tests / dev convenience.
|
// Leave v2 tables in place for subsequent tests / dev convenience.
|
||||||
// Cleanup happens via wp wpdo cleanup-uae-tables --confirm in real upgrades.
|
// Cleanup happens via wp wpdo cleanup-uae-tables --confirm in real upgrades.
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post write-back follows the same routing reads use.
|
||||||
|
*
|
||||||
|
* Listing 101 has `shared_key` in both group tables: 'L' in its own
|
||||||
|
* (hp_listing-scoped) group, a stray 'V' in the vendor group — the legacy
|
||||||
|
* owner, registered last. Whichever table reads use must be the one copied.
|
||||||
|
*/
|
||||||
|
class NativeRestorerPostTest extends TestCase {
|
||||||
|
|
||||||
|
private const LISTING = 'wp_itest_wpdo_post_nr_listing';
|
||||||
|
private const VENDOR = 'wp_itest_wpdo_post_nr_vendor';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-type-caster.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
'includes/migration/class-tmdo-native-restorer.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id), KEY post_id (post_id), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" );
|
||||||
|
foreach ( array( self::LISTING, self::VENDOR ) as $table ) {
|
||||||
|
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT '2020-01-01 00:00:00', PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB" );
|
||||||
|
}
|
||||||
|
$wpdb->insert( $wpdb->posts, array( 'ID' => 101, 'post_type' => 'hp_listing' ) );
|
||||||
|
$wpdb->insert( self::LISTING, array( 'post_id' => 101, 'shared_key' => 'L' ) );
|
||||||
|
$wpdb->insert( self::VENDOR, array( 'post_id' => 101, 'shared_key' => 'V' ) );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
$GLOBALS['_wp_transients'] = array();
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'nr_listing', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'nr_vendor', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) );
|
||||||
|
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( $wpdb->posts, $wpdb->postmeta, self::LISTING, self::VENDOR ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function native_value(): array {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->get_col( "SELECT meta_value FROM `{$wpdb->postmeta}` WHERE post_id = 101 AND meta_key = 'shared_key'" );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scoped_routing_writes_back_the_post_types_own_group(): void {
|
||||||
|
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '1' );
|
||||||
|
|
||||||
|
TMDO_Native_Restorer::run( 'post' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'L' ), $this->native_value() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_legacy_routing_writes_back_the_legacy_owner(): void {
|
||||||
|
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '0' );
|
||||||
|
|
||||||
|
TMDO_Native_Restorer::run( 'post' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'V' ), $this->native_value() );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retiring one entity group: write its flat values back to native meta, then
|
||||||
|
* forget it so orphan restore never routes its keys to the flat table again.
|
||||||
|
*
|
||||||
|
* Fixture (user, aeav_only): group rt_a (token, ips) and rt_b (other).
|
||||||
|
* rt_a user 1: token 't1', ips '["1.1.1.1"]' user 2: token 't2', ips NULL
|
||||||
|
* rt_b user 1: other 'o1'
|
||||||
|
*/
|
||||||
|
class NativeRestorerRetireTest extends TestCase {
|
||||||
|
|
||||||
|
private const A = 'wp_itest_wpdo_user_rt_a';
|
||||||
|
private const B = 'wp_itest_wpdo_user_rt_b';
|
||||||
|
private const META = 'wp_itest_wpdo_registry_meta';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-type-caster.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-user.php',
|
||||||
|
'includes/migration/class-tmdo-native-restorer.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::A . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, token VARCHAR(255) DEFAULT NULL, ips LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::B . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, other VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::A . "` (user_id, token, ips) VALUES (1, 't1', '[\"1.1.1.1\"]'), (2, 't2', NULL)" );
|
||||||
|
$wpdb->insert( self::B, array( 'user_id' => 1, 'other' => 'o1' ) );
|
||||||
|
$wpdb->query( 'UPDATE `' . self::A . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||||
|
$wpdb->query( 'UPDATE `' . self::B . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'aeav_only' ) );
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
$GLOBALS['_wp_transients'] = array();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
|
||||||
|
self::register( true );
|
||||||
|
$wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) );
|
||||||
|
$wpdb->insert(
|
||||||
|
self::META,
|
||||||
|
array(
|
||||||
|
'entity_type' => 'user',
|
||||||
|
'group_name' => 'rt_a',
|
||||||
|
'schema_hash' => 'itest',
|
||||||
|
'field_definitions' => wp_json_encode( WPDO_Entity_Registry::get_group_fields( 'user', 'rt_a' ) ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh registry; rt_b always registered, rt_a only while its provider is active. */
|
||||||
|
private static function register( bool $with_a ): void {
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||||
|
if ( $with_a ) {
|
||||||
|
WPDO_Entity_Registry::register_group(
|
||||||
|
'user',
|
||||||
|
'rt_a',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'token', 'type' => 'text' ),
|
||||||
|
array( 'key' => 'ips', 'type' => 'textarea' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
WPDO_Entity_Registry::register_group( 'user', 'rt_b', array( array( 'key' => 'other', 'type' => 'text' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( (array) $wpdb->get_col( "SHOW TABLES LIKE 'wp\\_itest\\_wpdo\\_user\\_rt\\_%'" ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" );
|
||||||
|
$wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function native( int $user_id, string $key ): array {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$wpdb->usermeta}` WHERE user_id = %d AND meta_key = %s", $user_id, $key ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_group_option_writes_back_only_that_group(): void {
|
||||||
|
$report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'rt_a' ), array_keys( $report['groups'] ) );
|
||||||
|
$this->assertSame( array( 't1' ), $this->native( 1, 'token' ) );
|
||||||
|
$this->assertSame( array( '["1.1.1.1"]' ), $this->native( 1, 'ips' ) );
|
||||||
|
$this->assertSame( array(), $this->native( 1, 'other' ), 'rt_b untouched' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retire_refuses_a_group_that_is_still_registered(): void {
|
||||||
|
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||||
|
|
||||||
|
$this->assertSame( 'registered', TMDO_Native_Restorer::retire( 'user', 'rt_a' )['status'] );
|
||||||
|
global $wpdb;
|
||||||
|
$this->assertSame( self::A, $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retire_refuses_when_native_meta_differs_from_the_flat_table(): void {
|
||||||
|
self::register( false );
|
||||||
|
|
||||||
|
$result = TMDO_Native_Restorer::retire( 'user', 'rt_a' );
|
||||||
|
|
||||||
|
$this->assertSame( 'mismatch', $result['status'] );
|
||||||
|
$this->assertSame( 3, $result['mismatches'], 't1, ["1.1.1.1"], t2 are not native yet' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_empty_string_values_count_as_written_back(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'UPDATE `' . self::A . "` SET ips = '' WHERE user_id = 2" );
|
||||||
|
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||||
|
self::register( false );
|
||||||
|
|
||||||
|
$this->assertSame( 'retired', TMDO_Native_Restorer::retire( 'user', 'rt_a' )['status'], "wpdb::get_col() reads '' as null" );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retire_forgets_the_group_and_keeps_its_table_as_a_backup(): void {
|
||||||
|
global $wpdb;
|
||||||
|
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||||
|
self::register( false );
|
||||||
|
|
||||||
|
$result = TMDO_Native_Restorer::retire( 'user', 'rt_a' );
|
||||||
|
|
||||||
|
$this->assertSame( 'retired', $result['status'] );
|
||||||
|
$this->assertMatchesRegularExpression( '/^' . self::A . '_retired_\d{8}$/', $result['archived_table'] );
|
||||||
|
$this->assertSame( $result['archived_table'], $wpdb->get_var( "SHOW TABLES LIKE '" . $result['archived_table'] . "'" ) );
|
||||||
|
$this->assertNull( $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) );
|
||||||
|
$this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::META . "` WHERE entity_type = 'user' AND group_name = 'rt_a'" ) );
|
||||||
|
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups(), 'orphan restore no longer routes the keys to the flat table' );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writing flat-table values back to native meta so an aeav_only entity can
|
||||||
|
* leave aeav_only.
|
||||||
|
*
|
||||||
|
* Rule: after the write-back, native meta returns what reads return today.
|
||||||
|
* Fixture (user entity, both groups hold `nickname`; the legacy owner is
|
||||||
|
* rn_extra, registered last):
|
||||||
|
* rn_profile user 1: nickname 'old', level 3, tags ["a","b"]
|
||||||
|
* user 2: nickname NULL, level 5, tags NULL
|
||||||
|
* rn_extra user 1: nickname 'live'
|
||||||
|
* usermeta user 1 level '1' (stale), user 2 tags 'stale'
|
||||||
|
*/
|
||||||
|
class NativeRestorerTest extends TestCase {
|
||||||
|
|
||||||
|
private const PROFILE = 'wp_itest_wpdo_user_rn_profile';
|
||||||
|
private const EXTRA = 'wp_itest_wpdo_user_rn_extra';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-type-caster.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-user.php',
|
||||||
|
'includes/migration/class-tmdo-native-restorer.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::PROFILE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, nickname VARCHAR(255) DEFAULT NULL, level BIGINT(20) DEFAULT NULL, tags LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::EXTRA . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, nickname VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::PROFILE . "` (user_id, nickname, level, tags) VALUES (1, 'old', 3, '[\"a\",\"b\"]'), (2, NULL, 5, NULL)" );
|
||||||
|
$wpdb->insert( self::EXTRA, array( 'user_id' => 1, 'nickname' => 'live' ) );
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'level', 'meta_value' => '1' ) );
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 2, 'meta_key' => 'tags', 'meta_value' => 'stale' ) );
|
||||||
|
// Historical rows: older than any run, so the catch-up pass leaves them alone.
|
||||||
|
$wpdb->query( 'UPDATE `' . self::PROFILE . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||||
|
$wpdb->query( 'UPDATE `' . self::EXTRA . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'aeav_only' ) );
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
$GLOBALS['_wp_transients'] = array();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||||
|
WPDO_Entity_Registry::register_group(
|
||||||
|
'user',
|
||||||
|
'rn_profile',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'nickname', 'type' => 'text' ),
|
||||||
|
array( 'key' => 'level', 'type' => 'integer' ),
|
||||||
|
array( 'key' => 'tags', 'type' => 'json' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
WPDO_Entity_Registry::register_group( 'user', 'rn_extra', array( array( 'key' => 'nickname', 'type' => 'text' ) ) );
|
||||||
|
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( $wpdb->usermeta, self::PROFILE, self::EXTRA ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] meta_value rows for (user, key). */
|
||||||
|
private function native( int $user_id, string $key ): array {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$wpdb->usermeta}` WHERE user_id = %d AND meta_key = %s", $user_id, $key ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dry_run_counts_without_writing(): void {
|
||||||
|
$report = TMDO_Native_Restorer::run( 'user', true );
|
||||||
|
|
||||||
|
$this->assertSame( 2, $report['groups']['rn_profile']['rows'] );
|
||||||
|
$this->assertSame( 1, $report['groups']['rn_extra']['rows'] );
|
||||||
|
$this->assertSame( array( '1' ), $this->native( 1, 'level' ) );
|
||||||
|
$this->assertFalse( $report['demoted'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_native_meta_ends_up_matching_current_reads(): void {
|
||||||
|
TMDO_Native_Restorer::run( 'user', false, 1 );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'live' ), $this->native( 1, 'nickname' ), 'the group reads use (rn_extra) wins' );
|
||||||
|
$this->assertSame( array( '3' ), $this->native( 1, 'level' ), 'stale native value replaced' );
|
||||||
|
$this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ), 'json stored the way WP stores arrays' );
|
||||||
|
$this->assertSame( array( '5' ), $this->native( 2, 'level' ) );
|
||||||
|
$this->assertSame( array(), $this->native( 2, 'tags' ), 'NULL flat value removes the stale native row' );
|
||||||
|
$this->assertSame( array(), $this->native( 2, 'nickname' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_completion_clears_progress_and_demote_leaves_aeav_only(): void {
|
||||||
|
$report = TMDO_Native_Restorer::run( 'user', false, 500, true );
|
||||||
|
|
||||||
|
$this->assertTrue( $report['demoted'] );
|
||||||
|
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) );
|
||||||
|
$this->assertFalse( get_option( TMDO_Native_Restorer::CURSOR_OPTION ) );
|
||||||
|
$this->assertFalse( get_option( TMDO_Native_Restorer::STARTED_OPTION ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resumes_after_the_stored_cursor(): void {
|
||||||
|
update_option( TMDO_Native_Restorer::CURSOR_OPTION, array( 'user/rn_profile' => 1 ) );
|
||||||
|
|
||||||
|
TMDO_Native_Restorer::run( 'user', false );
|
||||||
|
|
||||||
|
$this->assertSame( array( '1' ), $this->native( 1, 'level' ), 'user 1 was done before the interruption' );
|
||||||
|
$this->assertSame( array( '5' ), $this->native( 2, 'level' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_catch_up_copies_rows_written_during_the_run(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'UPDATE `' . self::PROFILE . "` SET level = 9, updated_at = NOW() + INTERVAL 1 HOUR WHERE user_id = 1" );
|
||||||
|
update_option( TMDO_Native_Restorer::CURSOR_OPTION, array( 'user/rn_profile' => 1 ) );
|
||||||
|
|
||||||
|
TMDO_Native_Restorer::run( 'user', false );
|
||||||
|
|
||||||
|
$this->assertSame( array( '9' ), $this->native( 1, 'level' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orphaned flat-table groups stay readable.
|
||||||
|
*
|
||||||
|
* Scenario: a plugin registered an entity group, its flat table was created
|
||||||
|
* (definition persisted in wpdo_registry_meta), data landed there, and then
|
||||||
|
* the plugin went away. In aeav_only mode the values exist only in the flat
|
||||||
|
* table, so without the group registration reads come back empty.
|
||||||
|
*/
|
||||||
|
class OrphanGroupRestoreTest extends TestCase {
|
||||||
|
|
||||||
|
private const GROUP = 'orphan_itest';
|
||||||
|
private const TABLE = 'wp_itest_wpdo_user_orphan_itest';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-type-caster.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-user.php',
|
||||||
|
'includes/engine/class-tmdo-hook-bus.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
WPDO_Installer::install_v2_tables();
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `{$wpdb->usermeta}` (
|
||||||
|
umeta_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
meta_key VARCHAR(255) DEFAULT NULL,
|
||||||
|
meta_value LONGTEXT,
|
||||||
|
PRIMARY KEY (umeta_id),
|
||||||
|
KEY meta_key (meta_key(191))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop_fixture();
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
$GLOBALS['_wp_transients'] = array();
|
||||||
|
self::reset_statics();
|
||||||
|
|
||||||
|
// A provider registered the group earlier: its flat table exists and the
|
||||||
|
// normalized definition was persisted to registry_meta (what
|
||||||
|
// Schema_Manager::create_or_upgrade_table() leaves behind — built by hand
|
||||||
|
// here because that method needs the real wp-admin upgrade.php).
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'user', self::GROUP, array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) );
|
||||||
|
$wpdb->query(
|
||||||
|
'CREATE TABLE `' . self::TABLE . '` (
|
||||||
|
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` BIGINT(20) NOT NULL,
|
||||||
|
`orphan_note` VARCHAR(255) DEFAULT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_entity` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||||
|
);
|
||||||
|
$wpdb->insert(
|
||||||
|
$wpdb->prefix . 'wpdo_registry_meta',
|
||||||
|
array(
|
||||||
|
'entity_type' => 'user',
|
||||||
|
'group_name' => self::GROUP,
|
||||||
|
'schema_hash' => 'itest',
|
||||||
|
'field_definitions' => wp_json_encode( WPDO_Entity_Registry::get_group_fields( 'user', self::GROUP ) ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$wpdb->insert( self::TABLE, array( 'user_id' => 77, 'orphan_note' => 'kept' ) );
|
||||||
|
|
||||||
|
// The provider goes away: next request starts with an empty registry.
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||||
|
self::reset_statics();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop_fixture();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
self::set_modes( null );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_orphaned_group_is_unreadable_before_restore(): void {
|
||||||
|
self::set_modes( 'aeav_only' );
|
||||||
|
|
||||||
|
$this->assertNull( WPDO_Hook_Bus::intercept_get( null, 77, 'orphan_note', true, 'user' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_restore_makes_aeav_only_data_readable_again(): void {
|
||||||
|
self::set_modes( 'aeav_only' );
|
||||||
|
|
||||||
|
$this->assertSame( 1, WPDO_Schema_Manager::restore_orphaned_groups() );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'kept' ), WPDO_Hook_Bus::intercept_get( null, 77, 'orphan_note', true, 'user' ) );
|
||||||
|
$this->assertSame( array(), WPDO_Entity_Registry::get_pending_schemas(), 'Restoring must not queue DDL.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_restore_only_applies_to_aeav_only(): void {
|
||||||
|
// dual_write / shadow_read still write native meta, so an orphaned key
|
||||||
|
// keeps reading correctly from there — nothing to restore.
|
||||||
|
foreach ( array( 'disabled', 'dual_write', 'shadow_read' ) as $mode ) {
|
||||||
|
self::set_modes( $mode );
|
||||||
|
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups(), $mode );
|
||||||
|
$this->assertNull( WPDO_Entity_Registry::get_field( 'user', 'orphan_note' ), $mode );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_key_with_native_meta_rows_stays_on_native(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::set_modes( 'aeav_only' );
|
||||||
|
// Written to native meta while the group was unregistered — newer than the flat copy.
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 5, 'meta_key' => 'orphan_note', 'meta_value' => 'native' ) );
|
||||||
|
|
||||||
|
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() );
|
||||||
|
|
||||||
|
$this->assertNull( WPDO_Hook_Bus::intercept_get( null, 5, 'orphan_note', true, 'user' ), 'Native meta must keep serving this key.' );
|
||||||
|
$this->assertSame( array( 'orphan_note' ), WPDO_Entity_Registry::get_restored_groups()[0]['native'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_native_meta_check_is_cached(): void {
|
||||||
|
self::set_modes( 'aeav_only' );
|
||||||
|
WPDO_Schema_Manager::restore_orphaned_groups();
|
||||||
|
|
||||||
|
$this->assertSame( array( 'user:orphan_note' => false ), get_transient( 'tmdo_orphan_native_keys' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_restore_skips_groups_whose_table_is_gone(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::set_modes( 'aeav_only' );
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||||
|
self::reset_statics();
|
||||||
|
|
||||||
|
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_registered_group_is_left_alone(): void {
|
||||||
|
self::set_modes( 'aeav_only' );
|
||||||
|
WPDO_Entity_Registry::register_group( 'user', self::GROUP, array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() );
|
||||||
|
$this->assertSame( array(), WPDO_Entity_Registry::get_restored_groups() );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function drop_fixture(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||||
|
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => self::GROUP ) );
|
||||||
|
$wpdb->delete( $wpdb->usermeta, array( 'meta_key' => 'orphan_note' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear Schema_Manager's table_exists cache and the Cache_Orchestrator L1. */
|
||||||
|
private static function reset_statics(): void {
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
|
||||||
|
$l1 = new ReflectionProperty( WPDO_Cache_Orchestrator::class, 'l1_cache' );
|
||||||
|
$l1->setAccessible( true );
|
||||||
|
$l1->setValue( null, array() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inject the user mode straight into Mode_Manager's cache (null = reset). */
|
||||||
|
private static function set_modes( ?string $user_mode ): void {
|
||||||
|
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue(
|
||||||
|
null,
|
||||||
|
null === $user_mode ? null : array(
|
||||||
|
'user' => $user_mode,
|
||||||
|
'post' => 'disabled',
|
||||||
|
'term' => 'disabled',
|
||||||
|
'comment' => 'disabled',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moving values written under legacy routing into their post type's group.
|
||||||
|
*
|
||||||
|
* Fixture (legacy owner of `shared_key` = rc_vendor, the last group registered):
|
||||||
|
* listing 101: vendor table 'live' | listing table 'backfill' (+ price 5)
|
||||||
|
* listing 102: vendor table 'only-in-vendor' | no listing row
|
||||||
|
* listing 103: no vendor row | listing table 'keep'
|
||||||
|
* listing 104: vendor row with NULL | listing table 'backfill-104'
|
||||||
|
* vendor 202: vendor table 'vendor own value'
|
||||||
|
*
|
||||||
|
* Reads before the switch returned the vendor-table value for 101/102, so
|
||||||
|
* those must win; 103/104 were never written after the backfill, so the
|
||||||
|
* listing value stays.
|
||||||
|
*/
|
||||||
|
class PostGroupReconcilerTest extends TestCase {
|
||||||
|
|
||||||
|
private const LISTING = 'wp_itest_wpdo_post_rc_listing';
|
||||||
|
private const VENDOR = 'wp_itest_wpdo_post_rc_vendor';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
'includes/migration/class-tmdo-post-group-reconciler.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||||
|
foreach ( array( 101 => 'hp_listing', 102 => 'hp_listing', 103 => 'hp_listing', 104 => 'hp_listing', 202 => 'hp_vendor' ) as $id => $type ) {
|
||||||
|
$wpdb->insert( $wpdb->posts, array( 'ID' => $id, 'post_type' => $type ) );
|
||||||
|
}
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::LISTING . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, price DECIMAL(18,6) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::VENDOR . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->insert( self::VENDOR, array( 'post_id' => 101, 'shared_key' => 'live' ) );
|
||||||
|
$wpdb->insert( self::VENDOR, array( 'post_id' => 102, 'shared_key' => 'only-in-vendor' ) );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::VENDOR . "` (post_id, shared_key) VALUES (104, NULL)" );
|
||||||
|
$wpdb->insert( self::VENDOR, array( 'post_id' => 202, 'shared_key' => 'vendor own value' ) );
|
||||||
|
$wpdb->insert( self::LISTING, array( 'post_id' => 101, 'shared_key' => 'backfill', 'price' => 5 ) );
|
||||||
|
$wpdb->insert( self::LISTING, array( 'post_id' => 103, 'shared_key' => 'keep' ) );
|
||||||
|
$wpdb->insert( self::LISTING, array( 'post_id' => 104, 'shared_key' => 'backfill-104' ) );
|
||||||
|
// Historical rows: older than any run, so the catch-up pass leaves them alone.
|
||||||
|
$wpdb->query( 'UPDATE `' . self::VENDOR . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||||
|
$wpdb->query( 'UPDATE `' . self::LISTING . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '0' );
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'rc_listing', array( array( 'key' => 'shared_key', 'type' => 'text' ), array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'rc_vendor', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( $wpdb->posts, self::LISTING, self::VENDOR ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listing_value( int $post_id ): ?string {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->get_var( $wpdb->prepare( 'SELECT shared_key FROM `' . self::LISTING . '` WHERE post_id = %d', $post_id ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dry_run_reports_without_writing(): void {
|
||||||
|
$report = TMDO_Post_Group_Reconciler::run( true );
|
||||||
|
|
||||||
|
$this->assertSame( 2, $report['moves'][0]['copied'], '101 and 102 hold non-NULL legacy values' );
|
||||||
|
$this->assertSame( 1, $report['moves'][0]['overwritten'], '101 has a different listing value' );
|
||||||
|
$this->assertFalse( $report['enabled'] );
|
||||||
|
$this->assertSame( 'backfill', $this->listing_value( 101 ) );
|
||||||
|
$this->assertNull( $this->listing_value( 102 ) );
|
||||||
|
$this->assertSame( '0', get_option( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_run_moves_the_values_reads_returned_before_the_switch(): void {
|
||||||
|
$report = TMDO_Post_Group_Reconciler::run( false, 1 );
|
||||||
|
|
||||||
|
$this->assertSame( 'live', $this->listing_value( 101 ) );
|
||||||
|
$this->assertSame( 'only-in-vendor', $this->listing_value( 102 ) );
|
||||||
|
$this->assertSame( 'keep', $this->listing_value( 103 ) );
|
||||||
|
$this->assertSame( 'backfill-104', $this->listing_value( 104 ), 'NULL legacy values do not erase the listing value' );
|
||||||
|
$this->assertNull( $this->listing_value( 202 ), 'vendor posts are not copied into the listing table' );
|
||||||
|
$this->assertSame( 2, $report['moves'][0]['copied'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_other_columns_of_the_target_row_are_untouched(): void {
|
||||||
|
global $wpdb;
|
||||||
|
TMDO_Post_Group_Reconciler::run( false );
|
||||||
|
|
||||||
|
$this->assertSame( 5.0, (float) $wpdb->get_var( 'SELECT price FROM `' . self::LISTING . '` WHERE post_id = 101' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_completion_turns_scoped_routing_on_and_clears_the_cursor(): void {
|
||||||
|
$report = TMDO_Post_Group_Reconciler::run( false );
|
||||||
|
|
||||||
|
$this->assertTrue( $report['enabled'] );
|
||||||
|
$this->assertSame( '1', get_option( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ) );
|
||||||
|
$this->assertFalse( get_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_catch_up_copies_rows_written_during_the_run(): void {
|
||||||
|
global $wpdb;
|
||||||
|
// Simulates a legacy-routed write that lands after the batch passed post 101.
|
||||||
|
$wpdb->query( 'UPDATE `' . self::VENDOR . "` SET shared_key = 'written during run', updated_at = NOW() + INTERVAL 1 HOUR WHERE post_id = 101" );
|
||||||
|
update_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION, array( 'rc_vendor>rc_listing:shared_key' => 101 ) );
|
||||||
|
|
||||||
|
TMDO_Post_Group_Reconciler::run( false );
|
||||||
|
|
||||||
|
$this->assertSame( 'written during run', $this->listing_value( 101 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resumes_after_the_stored_cursor(): void {
|
||||||
|
update_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION, array( 'rc_vendor>rc_listing:shared_key' => 101 ) );
|
||||||
|
|
||||||
|
TMDO_Post_Group_Reconciler::run( false );
|
||||||
|
|
||||||
|
$this->assertSame( 'backfill', $this->listing_value( 101 ), 'rows up to the cursor were already done' );
|
||||||
|
$this->assertSame( 'only-in-vendor', $this->listing_value( 102 ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routed searches filter status and sort by date on the flat table's
|
||||||
|
* post_status / post_date copies (O-1), when those copies are trustworthy.
|
||||||
|
*/
|
||||||
|
class PostQueryRouterCopyTest extends TestCase {
|
||||||
|
|
||||||
|
private const TABLE = 'wp_itest_wpdo_post_rc_core';
|
||||||
|
private const ALIAS = '`wpdoflat_rc_item_rc_core`';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
'includes/integrations/class-tmdo-post-fields.php',
|
||||||
|
'includes/engine/class-tmdo-post-status-sync.php',
|
||||||
|
'includes/query/class-tmdo-post-query-router.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date (post_status, post_date) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::TABLE . "` (post_id, rc_price, post_status, post_date) VALUES (1, 10, 'publish', '2026-01-01 00:00:00')" );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'ready' ) );
|
||||||
|
$GLOBALS['_wp_is_admin'] = false;
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$mode->setAccessible( true );
|
||||||
|
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
TMDO_Post_Query_Router::flush_copy_cache();
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'rc_core', array( array( 'key' => 'rc_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'rc_item' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$mode->setAccessible( true );
|
||||||
|
$mode->setValue( null, null );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{join:string, where:string, orderby:string, groupby:string} */
|
||||||
|
private function sql( array $vars ): array {
|
||||||
|
$query = new WP_Query();
|
||||||
|
foreach ( array_merge(
|
||||||
|
array(
|
||||||
|
'post_type' => 'rc_item',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN' ) ),
|
||||||
|
),
|
||||||
|
$vars
|
||||||
|
) as $k => $v ) {
|
||||||
|
$query->set( $k, $v );
|
||||||
|
}
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
$router->pre_get_posts( $query );
|
||||||
|
return array(
|
||||||
|
'join' => $router->posts_join( '', $query ),
|
||||||
|
'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ),
|
||||||
|
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
|
||||||
|
'groupby' => $router->posts_groupby( '', $query ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assert_legacy_shape( array $sql ): void {
|
||||||
|
$this->assertStringContainsString( 'LEFT JOIN', $sql['join'] );
|
||||||
|
$this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] );
|
||||||
|
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||||
|
$this->assertSame( '`wp_itest_posts`.`ID`', $sql['groupby'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void {
|
||||||
|
$sql = $this->sql( array( 'order' => 'ASC' ) );
|
||||||
|
|
||||||
|
$this->assertStringContainsString( 'INNER JOIN', $sql['join'] );
|
||||||
|
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] );
|
||||||
|
$this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' );
|
||||||
|
$this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] );
|
||||||
|
$this->assertSame( '', $sql['groupby'], 'one row per post: no GROUP BY, so LIMIT can stop early' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_several_explicit_statuses_are_kept(): void {
|
||||||
|
$sql = $this->sql( array( 'post_status' => array( 'publish', 'private' ), 'orderby' => 'date' ) );
|
||||||
|
|
||||||
|
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish','private')", $sql['where'] );
|
||||||
|
$this->assertSame( self::ALIAS . '.`post_date` DESC', $sql['orderby'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_copies_not_marked_ready_keep_the_legacy_shape(): void {
|
||||||
|
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'columns' ) );
|
||||||
|
|
||||||
|
$this->assert_legacy_shape( $this->sql( array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rows_with_missing_copies_keep_the_legacy_shape(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, rc_price) VALUES (2, 12)' );
|
||||||
|
|
||||||
|
$this->assert_legacy_shape( $this->sql( array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_other_orderings_or_implicit_statuses_keep_the_legacy_shape(): void {
|
||||||
|
$this->assert_legacy_shape( $this->sql( array( 'orderby' => 'title' ) ) );
|
||||||
|
$this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) );
|
||||||
|
$this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routed searches drop SQL_CALC_FOUND_ROWS; the total comes from a COUNT
|
||||||
|
* cached until posts' last_changed moves (O-4).
|
||||||
|
*
|
||||||
|
* Three fr_item posts, prices 10 / 20 / 30.
|
||||||
|
*/
|
||||||
|
class PostQueryRouterFoundRowsTest extends TestCase {
|
||||||
|
|
||||||
|
private const TABLE = 'wp_itest_wpdo_post_fr_core';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-type-caster.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
'includes/integrations/class-tmdo-post-fields.php',
|
||||||
|
'includes/engine/class-tmdo-hook-bus.php',
|
||||||
|
'includes/engine/class-tmdo-post-status-sync.php',
|
||||||
|
'includes/query/class-tmdo-post-query-router.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, fr_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type) VALUES (1, 'fr_item'), (2, 'fr_item'), (3, 'fr_item')" );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, fr_price) VALUES (1, 10), (2, 20), (3, 30)' );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
$GLOBALS['_wp_last_changed'] = array();
|
||||||
|
$GLOBALS['_wp_is_admin'] = false;
|
||||||
|
$GLOBALS['_wp_current_filter'] = '';
|
||||||
|
$GLOBALS['_wp_post_types'] = array( 1 => 'fr_item', 2 => 'fr_item', 3 => 'fr_item' );
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$mode->setAccessible( true );
|
||||||
|
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
TMDO_Post_Query_Router::flush_copy_cache();
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'fr_core', array( array( 'key' => 'fr_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'fr_item' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$mode->setAccessible( true );
|
||||||
|
$mode->setValue( null, null );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( $wpdb->posts, self::TABLE ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A routed query plus the SQL WordPress would build for it (page size 1). */
|
||||||
|
private function routed(): array {
|
||||||
|
global $wpdb;
|
||||||
|
$query = new WP_Query();
|
||||||
|
$query->set( 'post_type', 'fr_item' );
|
||||||
|
$query->set( 'post_status', 'publish' );
|
||||||
|
$query->set( 'meta_query', array( array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=' ) ) );
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
$router->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$request = "SELECT SQL_CALC_FOUND_ROWS {$wpdb->posts}.ID FROM {$wpdb->posts} " . $router->posts_join( '', $query )
|
||||||
|
. " WHERE 1=1 AND {$wpdb->posts}.post_type = 'fr_item' AND (({$wpdb->posts}.post_status = 'publish'))" . $router->posts_where( '', $query )
|
||||||
|
. " GROUP BY {$wpdb->posts}.ID ORDER BY {$wpdb->posts}.post_date DESC LIMIT 0, 1";
|
||||||
|
return array( $router, $query, $request );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_routed_request_drops_sql_calc_found_rows(): void {
|
||||||
|
list( $router, $query, $request ) = $this->routed();
|
||||||
|
|
||||||
|
$sql = $router->posts_request( $request, $query );
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $sql );
|
||||||
|
$this->assertStringContainsString( 'LIMIT 0, 1', $sql );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unrouted_request_is_left_alone(): void {
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
$request = 'SELECT SQL_CALC_FOUND_ROWS wp_itest_posts.ID FROM wp_itest_posts WHERE 1=1 LIMIT 0, 10';
|
||||||
|
|
||||||
|
$this->assertSame( $request, $router->posts_request( $request, new WP_Query() ) );
|
||||||
|
$this->assertSame( 'SELECT FOUND_ROWS()', $router->found_posts_query( 'SELECT FOUND_ROWS()', new WP_Query() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_is_counted_once_and_served_from_cache_until_posts_change(): void {
|
||||||
|
global $wpdb;
|
||||||
|
list( $router, $query, $request ) = $this->routed();
|
||||||
|
$router->posts_request( $request, $query );
|
||||||
|
|
||||||
|
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) );
|
||||||
|
|
||||||
|
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 2' );
|
||||||
|
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'cached' );
|
||||||
|
|
||||||
|
wp_cache_set_posts_last_changed();
|
||||||
|
$this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_flat_writes_and_deletes_move_posts_last_changed(): void {
|
||||||
|
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
||||||
|
$before = wp_cache_get_last_changed( 'posts' );
|
||||||
|
WPDO_Hook_Bus::intercept_update( null, 1, 'fr_price', '11', '' );
|
||||||
|
$after_write = wp_cache_get_last_changed( 'posts' );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_current_filter'] = 'delete_post_metadata';
|
||||||
|
WPDO_Hook_Bus::intercept_delete( null, 1, 'fr_price', '', false );
|
||||||
|
|
||||||
|
$this->assertNotSame( $before, $after_write );
|
||||||
|
$this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook Bus routes post meta to the group of the post's own type.
|
||||||
|
*
|
||||||
|
* Two groups share `shared_key`; the listing group is scoped to hp_listing,
|
||||||
|
* the vendor group to hp_vendor. Before scoping, both posts' values landed
|
||||||
|
* in the vendor table (last registered wins).
|
||||||
|
*/
|
||||||
|
class PostScopedRoutingTest extends TestCase {
|
||||||
|
|
||||||
|
private const LISTING_TABLE = 'wp_itest_wpdo_post_scope_listing';
|
||||||
|
private const VENDOR_TABLE = 'wp_itest_wpdo_post_scope_vendor';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-type-caster.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
'includes/engine/class-tmdo-hook-bus.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
foreach ( array( self::LISTING_TABLE, self::VENDOR_TABLE ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
$wpdb->query(
|
||||||
|
"CREATE TABLE `{$table}` (
|
||||||
|
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
|
||||||
|
`post_id` BIGINT(20) NOT NULL,
|
||||||
|
`shared_key` VARCHAR(255) DEFAULT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_entity` (`post_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tearDownAfterClass(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( self::LISTING_TABLE, self::VENDOR_TABLE ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, null );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( self::LISTING_TABLE, self::VENDOR_TABLE ) as $table ) {
|
||||||
|
$wpdb->query( "TRUNCATE TABLE `{$table}`" );
|
||||||
|
}
|
||||||
|
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '1' );
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
$GLOBALS['_wp_post_types'] = array( 101 => 'hp_listing', 202 => 'hp_vendor', 303 => 'post' );
|
||||||
|
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
||||||
|
|
||||||
|
$tec = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$tec->setAccessible( true );
|
||||||
|
$tec->setValue( null, array( self::LISTING_TABLE => true, self::VENDOR_TABLE => true ) );
|
||||||
|
$l1 = new ReflectionProperty( WPDO_Cache_Orchestrator::class, 'l1_cache' );
|
||||||
|
$l1->setAccessible( true );
|
||||||
|
$l1->setValue( null, array() );
|
||||||
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$mode->setAccessible( true );
|
||||||
|
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'scope_listing', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'scope_vendor', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function value_in( string $table, int $post_id ): ?string {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->get_var( $wpdb->prepare( "SELECT shared_key FROM `{$table}` WHERE post_id = %d", $post_id ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_each_post_type_writes_to_its_own_group_table(): void {
|
||||||
|
WPDO_Hook_Bus::intercept_update( null, 101, 'shared_key', 'listing value', '' );
|
||||||
|
WPDO_Hook_Bus::intercept_update( null, 202, 'shared_key', 'vendor value', '' );
|
||||||
|
|
||||||
|
$this->assertSame( 'listing value', $this->value_in( self::LISTING_TABLE, 101 ) );
|
||||||
|
$this->assertNull( $this->value_in( self::VENDOR_TABLE, 101 ) );
|
||||||
|
$this->assertSame( 'vendor value', $this->value_in( self::VENDOR_TABLE, 202 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reads_come_from_the_post_types_group_table(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->insert( self::LISTING_TABLE, array( 'post_id' => 101, 'shared_key' => 'from listing table' ) );
|
||||||
|
$wpdb->insert( self::VENDOR_TABLE, array( 'post_id' => 101, 'shared_key' => 'stray copy' ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'from listing table' ), WPDO_Hook_Bus::intercept_get( null, 101, 'shared_key', true, 'post' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_other_post_types_fall_through_to_native_meta(): void {
|
||||||
|
$this->assertNull( WPDO_Hook_Bus::intercept_update( null, 303, 'shared_key', 'x', '' ) );
|
||||||
|
$this->assertNull( WPDO_Hook_Bus::intercept_get( null, 303, 'shared_key', true, 'post' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_without_the_option_routing_stays_legacy(): void {
|
||||||
|
$GLOBALS['_wp_options'][ WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ] = '0';
|
||||||
|
|
||||||
|
WPDO_Hook_Bus::intercept_update( null, 101, 'shared_key', 'legacy', '' );
|
||||||
|
|
||||||
|
$this->assertSame( 'legacy', $this->value_in( self::VENDOR_TABLE, 101 ) );
|
||||||
|
$this->assertNull( $this->value_in( self::LISTING_TABLE, 101 ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* post_status / post_date copies on post entity tables (O-1 schema).
|
||||||
|
*
|
||||||
|
* Listing 1 (publish) has rows in both groups, draft 2 only in ps_a.
|
||||||
|
*/
|
||||||
|
class PostStatusSyncTest extends TestCase {
|
||||||
|
|
||||||
|
private const A = 'wp_itest_wpdo_post_ps_a';
|
||||||
|
private const B = 'wp_itest_wpdo_post_ps_b';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-type-caster.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
'includes/engine/class-tmdo-hook-bus.php',
|
||||||
|
'includes/engine/class-tmdo-post-status-sync.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci" );
|
||||||
|
foreach ( array( self::A, self::B ) as $table ) {
|
||||||
|
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, k VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" );
|
||||||
|
}
|
||||||
|
$wpdb->query( "INSERT INTO `{$wpdb->posts}` VALUES (1, 'hp_listing', 'publish', '2026-01-01 10:00:00'), (2, 'hp_listing', 'draft', '2026-02-01 10:00:00'), (3, 'hp_listing', 'pending', '2026-03-01 10:00:00')" );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::A . "` (post_id, k) VALUES (1, 'a1'), (2, 'a2')" );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::B . "` (post_id, k) VALUES (1, 'b1')" );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
$GLOBALS['_wp_posts'] = array();
|
||||||
|
$GLOBALS['_wp_current_filter'] = '';
|
||||||
|
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'ps_a', array( array( 'key' => 'k', 'type' => 'text' ) ) );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'ps_b', array( array( 'key' => 'k2', 'type' => 'text' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, null );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( $wpdb->posts, self::A, self::B ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array{0:?string, 1:?string}> post_id → [status, date] */
|
||||||
|
private function copies( string $table ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$out = array();
|
||||||
|
foreach ( (array) $wpdb->get_results( "SELECT post_id, post_status, post_date FROM `{$table}` ORDER BY post_id", ARRAY_A ) as $row ) {
|
||||||
|
$out[ (int) $row['post_id'] ] = array( $row['post_status'], $row['post_date'] );
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function has_index( string $table ): bool {
|
||||||
|
global $wpdb;
|
||||||
|
return in_array( 'idx_post_status_date', array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ), true );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void {
|
||||||
|
$report = TMDO_Post_Status_Sync::run( false, 1 );
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
array(
|
||||||
|
1 => array( 'publish', '2026-01-01 10:00:00' ),
|
||||||
|
2 => array( 'draft', '2026-02-01 10:00:00' ),
|
||||||
|
),
|
||||||
|
$this->copies( self::A )
|
||||||
|
);
|
||||||
|
$this->assertSame( array( 1 => array( 'publish', '2026-01-01 10:00:00' ) ), $this->copies( self::B ) );
|
||||||
|
$this->assertTrue( $this->has_index( self::A ) );
|
||||||
|
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
||||||
|
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_b' ) );
|
||||||
|
$this->assertSame( 0, TMDO_Post_Status_Sync::mismatches( 'ps_a' ) );
|
||||||
|
$this->assertSame( 2, $report['ps_a']['mismatched'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_copies_use_the_posts_collation_so_they_compare_with_posts(): void {
|
||||||
|
global $wpdb;
|
||||||
|
TMDO_Post_Status_Sync::run();
|
||||||
|
|
||||||
|
$column = $wpdb->get_row( 'SHOW FULL COLUMNS FROM `' . self::A . "` LIKE 'post_status'", ARRAY_A );
|
||||||
|
$this->assertSame( 'utf8mb4_unicode_520_ci', $column['Collation'] );
|
||||||
|
$wpdb->query( 'UPDATE `' . self::A . "` SET post_status = 'stale' WHERE post_id = 2" );
|
||||||
|
$this->assertSame( 1, TMDO_Post_Status_Sync::mismatches( 'ps_a' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dry_run_only_counts(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$report = TMDO_Post_Status_Sync::run( true );
|
||||||
|
|
||||||
|
$this->assertSame( 2, $report['ps_a']['mismatched'] );
|
||||||
|
$this->assertSame( 1, $report['ps_b']['mismatched'] );
|
||||||
|
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
|
||||||
|
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_status_transition_updates_the_post_in_every_group_table(): void {
|
||||||
|
TMDO_Post_Status_Sync::run();
|
||||||
|
|
||||||
|
TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::A )[1] );
|
||||||
|
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::B )[1] );
|
||||||
|
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2], 'other posts untouched' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_transition_leaves_tables_without_the_columns_alone(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->last_error = '';
|
||||||
|
|
||||||
|
TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) );
|
||||||
|
|
||||||
|
$this->assertSame( '', (string) $wpdb->last_error );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_new_empty_table_gets_the_columns_at_once_but_a_populated_one_waits_for_the_cli(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'TRUNCATE TABLE `' . self::B . '`' );
|
||||||
|
|
||||||
|
TMDO_Post_Status_Sync::on_schema_updated( 'post', 'ps_b', self::B );
|
||||||
|
TMDO_Post_Status_Sync::on_schema_updated( 'post', 'ps_a', self::A );
|
||||||
|
|
||||||
|
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_b' ) );
|
||||||
|
$this->assertTrue( $this->has_index( self::B ) );
|
||||||
|
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
||||||
|
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_flat_write_that_creates_a_row_fills_the_copies(): void {
|
||||||
|
TMDO_Post_Status_Sync::run();
|
||||||
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$mode->setAccessible( true );
|
||||||
|
$mode->setValue( null, array( 'post' => 'dual_write', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' );
|
||||||
|
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
||||||
|
|
||||||
|
WPDO_Hook_Bus::intercept_update( null, 3, 'k', 'a3', '' );
|
||||||
|
WPDO_Hook_Bus::intercept_update( null, 1, 'k', 'a1b', '' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'pending', '2026-03-01 10:00:00' ), $this->copies( self::A )[3] );
|
||||||
|
$this->assertSame( array( 'publish', '2026-01-01 10:00:00' ), $this->copies( self::A )[1], 'existing row keeps its synced copies' );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,12 +72,29 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Seed 5 rows.
|
// Posts table: listings are filtered by post_status against it.
|
||||||
for ( $i = 1; $i <= 5; $i++ ) {
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
|
||||||
|
$wpdb->query(
|
||||||
|
"CREATE TABLE `{$wpdb->posts}` (
|
||||||
|
ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
post_type VARCHAR(20) NOT NULL DEFAULT 'post',
|
||||||
|
post_status VARCHAR(20) NOT NULL DEFAULT 'publish',
|
||||||
|
PRIMARY KEY (ID)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed 5 published rows, plus draft/private/trash rows (ids 6-8) and
|
||||||
|
// a published post of another type (id 9) that must never be listed.
|
||||||
|
$statuses = array( 6 => 'draft', 7 => 'private', 8 => 'trash' );
|
||||||
|
for ( $i = 1; $i <= 8; $i++ ) {
|
||||||
$price = $i * 100;
|
$price = $i * 100;
|
||||||
$featured = $i % 2;
|
$featured = $i % 2;
|
||||||
|
$status = $statuses[ $i ] ?? 'publish';
|
||||||
|
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES ($i, 'restapi', '$status')" );
|
||||||
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" );
|
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" );
|
||||||
}
|
}
|
||||||
|
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES (9, 'post', 'publish')" );
|
||||||
|
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES (9, 900, 1)" );
|
||||||
|
|
||||||
// Set module to cutover so REST API reads from Zone A.
|
// Set module to cutover so REST API reads from Zone A.
|
||||||
WPDO_Feature_Flags::set( 'hot_restapi', 'cutover' );
|
WPDO_Feature_Flags::set( 'hot_restapi', 'cutover' );
|
||||||
@@ -87,6 +104,7 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
global $wpdb;
|
global $wpdb;
|
||||||
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" );
|
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" );
|
||||||
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
|
$wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" );
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
|
||||||
WPDO_Feature_Flags::reset( 'hot_restapi' );
|
WPDO_Feature_Flags::reset( 'hot_restapi' );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +115,7 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
$GLOBALS['_wp_current_user_can'] = [];
|
$GLOBALS['_wp_current_user_can'] = [];
|
||||||
$GLOBALS['_wp_valid_nonces'] = [];
|
$GLOBALS['_wp_valid_nonces'] = [];
|
||||||
$GLOBALS['_wp_transients'] = []; // Reset rate-limit transients between tests.
|
$GLOBALS['_wp_transients'] = []; // Reset rate-limit transients between tests.
|
||||||
|
$GLOBALS['_wp_registered_post_types'] = [ 'restapi' => true ];
|
||||||
// Note: do NOT reset _wp_options here — Feature Flags state is stored there.
|
// Note: do NOT reset _wp_options here — Feature Flags state is stored there.
|
||||||
|
|
||||||
// Invalidate Feature Flags static request cache so each test reads fresh.
|
// Invalidate Feature Flags static request cache so each test reads fresh.
|
||||||
@@ -232,6 +251,37 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
$this->assertSame( $sorted, $prices );
|
$this->assertSame( $sorted, $prices );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_listings_excludes_unpublished_posts(): void {
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'restapi' );
|
||||||
|
$req->set_param( 'per_page', 20 );
|
||||||
|
|
||||||
|
$response = self::$api->get_listings( $req );
|
||||||
|
$ids = array_column( $response->get_data(), 'id' );
|
||||||
|
sort( $ids );
|
||||||
|
|
||||||
|
$this->assertSame( array( 1, 2, 3, 4, 5 ), $ids, 'draft/private/trash and other post types must not be listed.' );
|
||||||
|
$this->assertSame( '5', $response->get_headers()['X-WP-Total'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_listings_filters_still_apply_with_status_join(): void {
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'restapi' );
|
||||||
|
$req->set_param( 'rp_price_min', 400 );
|
||||||
|
$req->set_param( 'per_page', 20 );
|
||||||
|
|
||||||
|
$ids = array_column( self::$api->get_listings( $req )->get_data(), 'id' );
|
||||||
|
sort( $ids );
|
||||||
|
$this->assertSame( array( 4, 5 ), $ids );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_listings_rejects_unregistered_post_type(): void {
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'nope' );
|
||||||
|
|
||||||
|
$this->assertSame( 400, self::$api->get_listings( $req )->get_status() );
|
||||||
|
}
|
||||||
|
|
||||||
// ── GET /listings/{id} ───────────────────────────────────────────────────
|
// ── GET /listings/{id} ───────────────────────────────────────────────────
|
||||||
|
|
||||||
public function test_get_listing_404_for_unknown_post(): void {
|
public function test_get_listing_404_for_unknown_post(): void {
|
||||||
|
|||||||
@@ -103,10 +103,11 @@ class SyncBridgeEntityGuardTest extends TestCase {
|
|||||||
'indexed' => false,
|
'indexed' => false,
|
||||||
) );
|
) );
|
||||||
|
|
||||||
// Register post adapter + post-fields groups (puts hp_price into Entity_Registry).
|
// Register post adapter + an entity group that owns hp_price (as the
|
||||||
|
// HivePress AddOn's hp_listing_core does).
|
||||||
WPDO_Entity_Registry::init();
|
WPDO_Entity_Registry::init();
|
||||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
WPDO_Post_Fields::register_entity_fields();
|
WPDO_Entity_Registry::register_group( 'post', 'hp_listing_core', array( array( 'key' => 'hp_price', 'type' => 'decimal', 'searchable' => true ) ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function tearDownAfterClass(): void {
|
public static function tearDownAfterClass(): void {
|
||||||
|
|||||||
@@ -152,9 +152,9 @@ class TermCommentGarbageFilterTest extends TestCase {
|
|||||||
|
|
||||||
// ── is_enabled toggle ───────────────────────────────────────────────────
|
// ── is_enabled toggle ───────────────────────────────────────────────────
|
||||||
|
|
||||||
public function test_is_enabled_defaults_true(): void {
|
public function test_is_enabled_defaults_false(): void {
|
||||||
delete_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED );
|
delete_option( WPDO_Term_Comment_Garbage_Filter::OPT_ENABLED );
|
||||||
$this->assertTrue( WPDO_Term_Comment_Garbage_Filter::is_enabled() );
|
$this->assertFalse( WPDO_Term_Comment_Garbage_Filter::is_enabled() );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_is_enabled_respects_zero_value(): void {
|
public function test_is_enabled_respects_zero_value(): void {
|
||||||
|
|||||||
@@ -189,11 +189,59 @@ class TermCommentMiscBucketTest extends TestCase {
|
|||||||
|
|
||||||
// ── is_enabled toggle ───────────────────────────────────────────────────
|
// ── is_enabled toggle ───────────────────────────────────────────────────
|
||||||
|
|
||||||
public function test_is_enabled_defaults_true(): void {
|
public function test_is_enabled_defaults_false(): void {
|
||||||
delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
|
delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
|
||||||
|
$this->assertFalse( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── one-time default migration for sites that already use the bucket ─────
|
||||||
|
|
||||||
|
public function test_preserve_default_keeps_bucket_on_when_misc_has_rows(): void {
|
||||||
|
$this->reset_preserve_state();
|
||||||
|
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 1, 'a', 'x', '' );
|
||||||
|
|
||||||
|
TMDO_Installer::maybe_preserve_misc_bucket_default();
|
||||||
|
|
||||||
|
$this->assertSame( '1', get_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED ) );
|
||||||
$this->assertTrue( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
|
$this->assertTrue( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_preserve_default_leaves_bucket_off_when_misc_empty(): void {
|
||||||
|
$this->reset_preserve_state();
|
||||||
|
|
||||||
|
TMDO_Installer::maybe_preserve_misc_bucket_default();
|
||||||
|
|
||||||
|
$this->assertFalse( get_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED ) );
|
||||||
|
$this->assertFalse( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
|
||||||
|
$this->assertNotEmpty( get_option( TMDO_Installer::MISC_BUCKET_DEFAULT_FLAG ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_preserve_default_respects_explicit_off(): void {
|
||||||
|
$this->reset_preserve_state();
|
||||||
|
update_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '0' );
|
||||||
|
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 1, 'a', 'x', '' );
|
||||||
|
|
||||||
|
TMDO_Installer::maybe_preserve_misc_bucket_default();
|
||||||
|
|
||||||
|
$this->assertSame( '0', get_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED ) );
|
||||||
|
delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_preserve_default_runs_only_once(): void {
|
||||||
|
$this->reset_preserve_state();
|
||||||
|
TMDO_Installer::maybe_preserve_misc_bucket_default(); // Empty tables: stays off, flag set.
|
||||||
|
WPDO_Term_Comment_Misc_Bucket::on_term_update( null, 1, 'a', 'x', '' );
|
||||||
|
|
||||||
|
TMDO_Installer::maybe_preserve_misc_bucket_default();
|
||||||
|
|
||||||
|
$this->assertFalse( get_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reset_preserve_state(): void {
|
||||||
|
delete_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
|
||||||
|
delete_option( TMDO_Installer::MISC_BUCKET_DEFAULT_FLAG );
|
||||||
|
}
|
||||||
|
|
||||||
public function test_is_enabled_respects_zero_value(): void {
|
public function test_is_enabled_respects_zero_value(): void {
|
||||||
update_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '0' );
|
update_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '0' );
|
||||||
$this->assertFalse( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
|
$this->assertFalse( WPDO_Term_Comment_Misc_Bucket::is_enabled() );
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: uninstall refuses to drop tables while data lives only in them.
|
||||||
|
*
|
||||||
|
* Covers TMDO_Installer::uninstall_blockers() and, end to end, that uninstall.php
|
||||||
|
* stops (wp_die) before touching any table or option.
|
||||||
|
*/
|
||||||
|
class UninstallGuardTest extends TestCase {
|
||||||
|
|
||||||
|
private const TERM_MISC = 'wp_itest_wpdo_term_misc';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `' . self::TERM_MISC . '` (
|
||||||
|
term_id bigint(20) unsigned NOT NULL,
|
||||||
|
meta_key varchar(191) NOT NULL,
|
||||||
|
meta_value longtext,
|
||||||
|
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (term_id, meta_key)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->prefix = 'wp_itest_';
|
||||||
|
$wpdb->options = 'wp_itest_options';
|
||||||
|
$wpdb->query( 'TRUNCATE TABLE `' . self::TERM_MISC . '`' );
|
||||||
|
delete_option( 'wpdo_bridge_modes' );
|
||||||
|
delete_option( 'wpdo_features' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_clean_site_has_no_blockers(): void {
|
||||||
|
$this->assertSame( array(), TMDO_Installer::uninstall_blockers() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aeav_only_mode_blocks(): void {
|
||||||
|
update_option( 'wpdo_bridge_modes', array( 'post' => 'aeav_only', 'user' => 'dual_write' ) );
|
||||||
|
|
||||||
|
$blockers = TMDO_Installer::uninstall_blockers();
|
||||||
|
|
||||||
|
$this->assertCount( 1, $blockers );
|
||||||
|
$this->assertStringContainsString( 'post', $blockers[0] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_modes_that_still_write_native_do_not_block(): void {
|
||||||
|
update_option( 'wpdo_bridge_modes', array( 'user' => 'dual_write', 'term' => 'shadow_read', 'comment' => 'disabled' ) );
|
||||||
|
|
||||||
|
$this->assertSame( array(), TMDO_Installer::uninstall_blockers() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_misc_rows_block(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::TERM_MISC . "` (term_id, meta_key, meta_value) VALUES (1, 'k', 'v')" );
|
||||||
|
|
||||||
|
$blockers = TMDO_Installer::uninstall_blockers();
|
||||||
|
|
||||||
|
$this->assertCount( 1, $blockers );
|
||||||
|
$this->assertStringContainsString( 'wpdo_term_misc', $blockers[0] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_zone_module_past_cutover_blocks(): void {
|
||||||
|
update_option( 'wpdo_features', array( 'hot_hp_listing' => 'complete', 'cold_hp_vendor' => 'cleanup', 'warm' => 'cutover' ) );
|
||||||
|
|
||||||
|
$blockers = TMDO_Installer::uninstall_blockers();
|
||||||
|
|
||||||
|
$this->assertCount( 2, $blockers );
|
||||||
|
$this->assertStringContainsString( 'hot_hp_listing', implode( ' ', $blockers ) );
|
||||||
|
$this->assertStringContainsString( 'cold_hp_vendor', implode( ' ', $blockers ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_hpct_module_complete_does_not_block(): void {
|
||||||
|
// HPCT modules live in hpct_* tables, which uninstall never drops.
|
||||||
|
update_option( 'wpdo_features', array( 'reviews' => 'complete' ) );
|
||||||
|
|
||||||
|
$this->assertSame( array(), TMDO_Installer::uninstall_blockers() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_uninstall_script_refuses_and_keeps_data(): void {
|
||||||
|
global $wpdb;
|
||||||
|
update_option( 'wpdo_bridge_modes', array( 'post' => 'aeav_only' ) );
|
||||||
|
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
||||||
|
define( 'WP_UNINSTALL_PLUGIN', '2meet-data-optimizer/2meet-data-optimizer.php' );
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
require TMDO_PATH . 'uninstall.php';
|
||||||
|
$this->fail( 'uninstall.php should have stopped with wp_die()' );
|
||||||
|
} catch ( RuntimeException $e ) {
|
||||||
|
$this->assertStringContainsString( 'aeav_only', $e->getMessage() );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame( array( 'post' => 'aeav_only' ), get_option( 'wpdo_bridge_modes' ) );
|
||||||
|
$this->assertSame( self::TERM_MISC, $wpdb->get_var( "SHOW TABLES LIKE '" . self::TERM_MISC . "'" ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_uninstall_script_checks_every_site_on_multisite(): void {
|
||||||
|
update_option( 'wpdo_bridge_modes', array( 'user' => 'aeav_only' ) );
|
||||||
|
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
||||||
|
define( 'WP_UNINSTALL_PLUGIN', '2meet-data-optimizer/2meet-data-optimizer.php' );
|
||||||
|
}
|
||||||
|
$GLOBALS['_wp_is_multisite'] = true;
|
||||||
|
$GLOBALS['_wp_sites'] = array( 1, 3 );
|
||||||
|
|
||||||
|
try {
|
||||||
|
require TMDO_PATH . 'uninstall.php';
|
||||||
|
$this->fail( 'uninstall.php should have stopped with wp_die()' );
|
||||||
|
} catch ( RuntimeException $e ) {
|
||||||
|
$this->assertStringContainsString( '站台 #1', $e->getMessage() );
|
||||||
|
$this->assertStringContainsString( '站台 #3', $e->getMessage() );
|
||||||
|
} finally {
|
||||||
|
unset( $GLOBALS['_wp_is_multisite'], $GLOBALS['_wp_sites'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ require_once dirname( __DIR__, 2 ) . '/vendor/autoload.php';
|
|||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
define( 'ABSPATH', '/fake/wordpress/' );
|
// tests/bench/seed.php pre-defines ABSPATH so code that requires wp-admin files can run.
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) { define( 'ABSPATH', '/fake/wordpress/' ); }
|
||||||
define( 'TMDO_PATH', dirname( __DIR__, 2 ) . '/' );
|
define( 'TMDO_PATH', dirname( __DIR__, 2 ) . '/' );
|
||||||
define( 'TMDO_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer/' );
|
define( 'TMDO_URL', 'http://localhost/wp-content/plugins/2meet-data-optimizer/' );
|
||||||
define( 'TMDO_FILE', TMDO_PATH . '2meet-data-optimizer.php' );
|
define( 'TMDO_FILE', TMDO_PATH . '2meet-data-optimizer.php' );
|
||||||
@@ -128,7 +129,8 @@ $wpdb = new class( $_mysqli ) {
|
|||||||
public function get_var( string $sql ): ?string {
|
public function get_var( string $sql ): ?string {
|
||||||
$result = $this->db->query( $sql );
|
$result = $this->db->query( $sql );
|
||||||
if ( ! $result || ! ( $row = $result->fetch_row() ) ) { return null; }
|
if ( ! $result || ! ( $row = $result->fetch_row() ) ) { return null; }
|
||||||
return $row[0] !== null ? (string) $row[0] : null;
|
// Like wpdb::get_var(): an empty string reads as null.
|
||||||
|
return null !== $row[0] && '' !== $row[0] ? (string) $row[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get_row( string $sql, $output = 'OBJECT' ) {
|
public function get_row( string $sql, $output = 'OBJECT' ) {
|
||||||
@@ -151,7 +153,8 @@ $wpdb = new class( $_mysqli ) {
|
|||||||
$result = $this->db->query( $sql );
|
$result = $this->db->query( $sql );
|
||||||
if ( ! $result ) { return []; }
|
if ( ! $result ) { return []; }
|
||||||
$values = [];
|
$values = [];
|
||||||
while ( $row = $result->fetch_row() ) { $values[] = $row[ $col_index ] ?? null; }
|
// Like wpdb::get_col() (per-row get_var()): an empty string reads as null.
|
||||||
|
while ( $row = $result->fetch_row() ) { $values[] = isset( $row[ $col_index ] ) && '' !== $row[ $col_index ] ? $row[ $col_index ] : null; }
|
||||||
return $values;
|
return $values;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +304,7 @@ if ( ! function_exists( 'wp_strip_all_tags' ) ) { function wp_strip_all_tags( st
|
|||||||
if ( ! function_exists( 'get_option' ) ) { function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; } }
|
if ( ! function_exists( 'get_option' ) ) { function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; } }
|
||||||
if ( ! function_exists( 'update_option' ) ) { function update_option( string $key, $value ): bool { $GLOBALS['_wp_options'][ $key ] = $value; return true; } }
|
if ( ! function_exists( 'update_option' ) ) { function update_option( string $key, $value ): bool { $GLOBALS['_wp_options'][ $key ] = $value; return true; } }
|
||||||
if ( ! function_exists( 'delete_option' ) ) { function delete_option( string $key ): bool { unset( $GLOBALS['_wp_options'][ $key ] ); return true; } }
|
if ( ! function_exists( 'delete_option' ) ) { function delete_option( string $key ): bool { unset( $GLOBALS['_wp_options'][ $key ] ); return true; } }
|
||||||
|
if ( ! function_exists( 'add_option' ) ) { function add_option( string $key, $value = '', $deprecated = '', $autoload = null ): bool { if ( array_key_exists( $key, $GLOBALS['_wp_options'] ?? array() ) ) { return false; } $GLOBALS['_wp_options'][ $key ] = $value; return true; } }
|
||||||
if ( ! function_exists( 'get_transient' ) ) { function get_transient( string $key ) { return $GLOBALS['_wp_transients'][ $key ] ?? false; } }
|
if ( ! function_exists( 'get_transient' ) ) { function get_transient( string $key ) { return $GLOBALS['_wp_transients'][ $key ] ?? false; } }
|
||||||
if ( ! function_exists( 'set_transient' ) ) { function set_transient( string $key, $value, int $exp = 0 ): bool { $GLOBALS['_wp_transients'][ $key ] = $value; return true; } }
|
if ( ! function_exists( 'set_transient' ) ) { function set_transient( string $key, $value, int $exp = 0 ): bool { $GLOBALS['_wp_transients'][ $key ] = $value; return true; } }
|
||||||
if ( ! function_exists( 'delete_transient' ) ) { function delete_transient( string $key ): bool { unset( $GLOBALS['_wp_transients'][ $key ] ); return true; } }
|
if ( ! function_exists( 'delete_transient' ) ) { function delete_transient( string $key ): bool { unset( $GLOBALS['_wp_transients'][ $key ] ); return true; } }
|
||||||
@@ -327,6 +331,7 @@ if ( ! function_exists( 'get_term_meta' ) ) { function get_term_meta( int $tid,
|
|||||||
if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } }
|
if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } }
|
||||||
if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } }
|
if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } }
|
||||||
if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } }
|
if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } }
|
||||||
|
if ( ! function_exists( 'get_post' ) ) { function get_post( $post_id ) { return $GLOBALS['_wp_posts'][ (int) $post_id ] ?? null; } }
|
||||||
if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
|
if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
|
||||||
if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
|
if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
|
||||||
if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
|
if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
|
||||||
@@ -335,6 +340,9 @@ if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
|
|||||||
return 'publish' === ( $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish' );
|
return 'publish' === ( $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish' );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Registered post types: name => publicly viewable (bool).
|
||||||
|
if ( ! function_exists( 'post_type_exists' ) ) { function post_type_exists( string $post_type ): bool { return isset( $GLOBALS['_wp_registered_post_types'][ $post_type ] ); } }
|
||||||
|
if ( ! function_exists( 'is_post_type_viewable' ) ) { function is_post_type_viewable( $post_type ): bool { return ! empty( $GLOBALS['_wp_registered_post_types'][ (string) $post_type ] ); } }
|
||||||
if ( ! function_exists( 'current_user_can' ) ) {
|
if ( ! function_exists( 'current_user_can' ) ) {
|
||||||
function current_user_can( string $cap, ...$args ): bool {
|
function current_user_can( string $cap, ...$args ): bool {
|
||||||
if ( ! empty( $args ) ) { $key = $cap . ':' . implode( ',', array_map( 'strval', $args ) ); if ( isset( $GLOBALS['_wp_current_user_can'][ $key ] ) ) { return (bool) $GLOBALS['_wp_current_user_can'][ $key ]; } }
|
if ( ! empty( $args ) ) { $key = $cap . ':' . implode( ',', array_map( 'strval', $args ) ); if ( isset( $GLOBALS['_wp_current_user_can'][ $key ] ) ) { return (bool) $GLOBALS['_wp_current_user_can'][ $key ]; } }
|
||||||
@@ -402,6 +410,8 @@ if ( ! function_exists( 'wp_mkdir_p' ) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$GLOBALS['_wp_cache'] = [];
|
$GLOBALS['_wp_cache'] = [];
|
||||||
|
if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } }
|
||||||
|
if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } }
|
||||||
if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } }
|
if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } }
|
||||||
if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
|
if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
|
||||||
if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; } }
|
if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; } }
|
||||||
@@ -577,6 +587,7 @@ require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php
|
|||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
|
||||||
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
|
||||||
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
|
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
|
||||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
|
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
|
||||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
|
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
|
||||||
@@ -597,6 +608,8 @@ require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-cleanup.php
|
|||||||
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
|
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
|
||||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
|
||||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
|
||||||
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
|
||||||
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
|
||||||
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
|
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
|
||||||
require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php';
|
require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php';
|
||||||
require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php';
|
require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php';
|
||||||
|
|||||||
@@ -65,65 +65,65 @@ class FSMAdvisorTest extends TestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function test_idle_state_returns_HOLD(): void {
|
public function test_idle_state_returns_HOLD(): void {
|
||||||
$this->set_module_state( 'reviews', 'idle' );
|
$this->set_module_state( 'wc_orders', 'idle' );
|
||||||
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
|
$advice = WPDO_FSM_Advisor::advise( 'wc_orders' );
|
||||||
$this->assertSame( 'HOLD', $advice['action'] );
|
$this->assertSame( 'HOLD', $advice['action'] );
|
||||||
$this->assertSame( 'info', $advice['level'] );
|
$this->assertSame( 'info', $advice['level'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_complete_state_returns_HOLD(): void {
|
public function test_complete_state_returns_HOLD(): void {
|
||||||
$this->set_module_state( 'reviews', 'complete' );
|
$this->set_module_state( 'wc_orders', 'complete' );
|
||||||
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
|
$advice = WPDO_FSM_Advisor::advise( 'wc_orders' );
|
||||||
$this->assertSame( 'HOLD', $advice['action'] );
|
$this->assertSame( 'HOLD', $advice['action'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_dual_write_with_short_soak_returns_WAIT(): void {
|
public function test_dual_write_with_short_soak_returns_WAIT(): void {
|
||||||
// In dual_write 0 days < 1 day min soak.
|
// In dual_write 0 days < 1 day min soak.
|
||||||
$this->set_module_state( 'reviews', 'dual_write', 0 );
|
$this->set_module_state( 'wc_orders', 'dual_write', 0 );
|
||||||
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
|
$advice = WPDO_FSM_Advisor::advise( 'wc_orders' );
|
||||||
$this->assertSame( 'WAIT', $advice['action'] );
|
$this->assertSame( 'WAIT', $advice['action'] );
|
||||||
$this->assertGreaterThanOrEqual( 1, $advice['days_remaining'] );
|
$this->assertGreaterThanOrEqual( 1, $advice['days_remaining'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_dual_write_after_min_soak_returns_PROMOTE(): void {
|
public function test_dual_write_after_min_soak_returns_PROMOTE(): void {
|
||||||
$this->set_module_state( 'reviews', 'dual_write', 2 );
|
$this->set_module_state( 'wc_orders', 'dual_write', 2 );
|
||||||
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
|
$advice = WPDO_FSM_Advisor::advise( 'wc_orders' );
|
||||||
$this->assertSame( 'PROMOTE', $advice['action'] );
|
$this->assertSame( 'PROMOTE', $advice['action'] );
|
||||||
$this->assertSame( 'backfill', $advice['next_state'] );
|
$this->assertSame( 'backfill', $advice['next_state'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_verify_with_long_soak_returns_PROMOTE(): void {
|
public function test_verify_with_long_soak_returns_PROMOTE(): void {
|
||||||
// verify needs 7 days; 10 days = should promote.
|
// verify needs 7 days; 10 days = should promote.
|
||||||
$this->set_module_state( 'reviews', 'verify', 10 );
|
$this->set_module_state( 'wc_orders', 'verify', 10 );
|
||||||
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
|
$advice = WPDO_FSM_Advisor::advise( 'wc_orders' );
|
||||||
$this->assertSame( 'PROMOTE', $advice['action'] );
|
$this->assertSame( 'PROMOTE', $advice['action'] );
|
||||||
$this->assertSame( 'cutover', $advice['next_state'] );
|
$this->assertSame( 'cutover', $advice['next_state'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_verify_with_short_soak_returns_WAIT(): void {
|
public function test_verify_with_short_soak_returns_WAIT(): void {
|
||||||
$this->set_module_state( 'reviews', 'verify', 3 );
|
$this->set_module_state( 'wc_orders', 'verify', 3 );
|
||||||
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
|
$advice = WPDO_FSM_Advisor::advise( 'wc_orders' );
|
||||||
$this->assertSame( 'WAIT', $advice['action'] );
|
$this->assertSame( 'WAIT', $advice['action'] );
|
||||||
$this->assertSame( 4, $advice['days_remaining'] );
|
$this->assertSame( 4, $advice['days_remaining'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_cleanup_with_short_wash_returns_WAIT(): void {
|
public function test_cleanup_with_short_wash_returns_WAIT(): void {
|
||||||
$this->set_module_state( 'reviews', 'cleanup', 1 );
|
$this->set_module_state( 'wc_orders', 'cleanup', 1 );
|
||||||
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
|
$advice = WPDO_FSM_Advisor::advise( 'wc_orders' );
|
||||||
$this->assertSame( 'WAIT', $advice['action'] );
|
$this->assertSame( 'WAIT', $advice['action'] );
|
||||||
$this->assertSame( 2, $advice['days_remaining'] );
|
$this->assertSame( 2, $advice['days_remaining'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_cleanup_after_wash_returns_PROMOTE(): void {
|
public function test_cleanup_after_wash_returns_PROMOTE(): void {
|
||||||
$this->set_module_state( 'reviews', 'cleanup', 5 );
|
$this->set_module_state( 'wc_orders', 'cleanup', 5 );
|
||||||
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
|
$advice = WPDO_FSM_Advisor::advise( 'wc_orders' );
|
||||||
$this->assertSame( 'PROMOTE', $advice['action'] );
|
$this->assertSame( 'PROMOTE', $advice['action'] );
|
||||||
$this->assertSame( 'complete', $advice['next_state'] );
|
$this->assertSame( 'complete', $advice['next_state'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_advice_metrics_include_state_and_soak(): void {
|
public function test_advice_metrics_include_state_and_soak(): void {
|
||||||
$this->set_module_state( 'reviews', 'dual_write', 5 );
|
$this->set_module_state( 'wc_orders', 'dual_write', 5 );
|
||||||
$advice = WPDO_FSM_Advisor::advise( 'reviews' );
|
$advice = WPDO_FSM_Advisor::advise( 'wc_orders' );
|
||||||
$this->assertSame( 'dual_write', $advice['metrics']['state'] );
|
$this->assertSame( 'dual_write', $advice['metrics']['state'] );
|
||||||
$this->assertSame( 5, $advice['metrics']['days_in_state'] );
|
$this->assertSame( 5, $advice['metrics']['days_in_state'] );
|
||||||
$this->assertSame( 1, $advice['metrics']['min_soak_days'] );
|
$this->assertSame( 1, $advice['metrics']['min_soak_days'] );
|
||||||
@@ -132,8 +132,8 @@ class FSMAdvisorTest extends TestCase {
|
|||||||
public function test_advise_all_returns_map_for_all_modules(): void {
|
public function test_advise_all_returns_map_for_all_modules(): void {
|
||||||
$advice = WPDO_FSM_Advisor::advise_all();
|
$advice = WPDO_FSM_Advisor::advise_all();
|
||||||
$this->assertNotEmpty( $advice );
|
$this->assertNotEmpty( $advice );
|
||||||
// All known HPCT + zone modules should be present (default idle).
|
// All known AddOn + zone modules should be present (default idle).
|
||||||
foreach ( WPDO_Feature_Flags::HPCT_MODULES as $m ) {
|
foreach ( WPDO_Feature_Flags::ADDON_MODULES as $m ) {
|
||||||
$this->assertArrayHasKey( $m, $advice );
|
$this->assertArrayHasKey( $m, $advice );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,15 +67,15 @@ class ModuleDetectorTest extends TestCase {
|
|||||||
|
|
||||||
public function test_module_rules_known_modules_count(): void {
|
public function test_module_rules_known_modules_count(): void {
|
||||||
$modules = WPDO_Module_Rules::known_modules();
|
$modules = WPDO_Module_Rules::known_modules();
|
||||||
$this->assertGreaterThanOrEqual( 14, count( $modules ), '預期 ≥ 14 個 module(9 HPCT + 6 zone - 1 dup)' );
|
$this->assertGreaterThanOrEqual( 8, count( $modules ), '預期 ≥ 8 個 module(2 AddOn + 6 zone)' );
|
||||||
$this->assertContains( 'reviews', $modules );
|
$this->assertContains( 'wc_orders', $modules );
|
||||||
$this->assertContains( 'warm', $modules );
|
$this->assertContains( 'warm', $modules );
|
||||||
$this->assertContains( 'archive', $modules );
|
$this->assertContains( 'archive', $modules );
|
||||||
$this->assertContains( 'hot_hp_listing', $modules );
|
$this->assertContains( 'hot_hp_listing', $modules );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_module_rule_for_module_returns_array(): void {
|
public function test_module_rule_for_module_returns_array(): void {
|
||||||
$rule = WPDO_Module_Rules::for_module( 'reviews' );
|
$rule = WPDO_Module_Rules::for_module( 'wc_orders' );
|
||||||
$this->assertIsArray( $rule );
|
$this->assertIsArray( $rule );
|
||||||
$this->assertArrayHasKey( 'compat_required', $rule );
|
$this->assertArrayHasKey( 'compat_required', $rule );
|
||||||
$this->assertArrayHasKey( 'description', $rule );
|
$this->assertArrayHasKey( 'description', $rule );
|
||||||
@@ -86,9 +86,9 @@ class ModuleDetectorTest extends TestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function test_detector_skip_when_module_not_idle(): void {
|
public function test_detector_skip_when_module_not_idle(): void {
|
||||||
// Set reviews to dual_write.
|
// Set wc_orders to dual_write.
|
||||||
WPDO_Feature_Flags::set( 'reviews', 'dual_write' );
|
WPDO_Feature_Flags::set( 'wc_orders', 'dual_write' );
|
||||||
$r = WPDO_Module_Detector::detect_one( 'reviews' );
|
$r = WPDO_Module_Detector::detect_one( 'wc_orders' );
|
||||||
$this->assertSame( 'skip', $r['recommendation'] );
|
$this->assertSame( 'skip', $r['recommendation'] );
|
||||||
$this->assertFalse( $r['available'] );
|
$this->assertFalse( $r['available'] );
|
||||||
$this->assertNotEmpty( $r['blockers'] );
|
$this->assertNotEmpty( $r['blockers'] );
|
||||||
@@ -123,9 +123,9 @@ class ModuleDetectorTest extends TestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function test_detector_hivepress_required_blocks_when_inactive(): void {
|
public function test_detector_hivepress_required_blocks_when_inactive(): void {
|
||||||
// reviews requires hivepress; Compatibility class isn't loaded in this test.
|
// hot_hp_listing requires hivepress; Compatibility class isn't loaded in this test.
|
||||||
// Without Compatibility class, the helper returns the full required list as missing.
|
// Without Compatibility class, the helper returns the full required list as missing.
|
||||||
$r = WPDO_Module_Detector::detect_one( 'reviews' );
|
$r = WPDO_Module_Detector::detect_one( 'hot_hp_listing' );
|
||||||
$this->assertSame( 'skip', $r['recommendation'] );
|
$this->assertSame( 'skip', $r['recommendation'] );
|
||||||
$this->assertNotEmpty( $r['blockers'] );
|
$this->assertNotEmpty( $r['blockers'] );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once dirname( __DIR__ ) . '/bench/class-tmdo-bench-seeder.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit test: TMDO_Bench_Seeder guards and deterministic generators.
|
||||||
|
*
|
||||||
|
* The seeder drops and rebuilds tables, so the database-name guard is the
|
||||||
|
* only thing standing between it and a production database.
|
||||||
|
*/
|
||||||
|
class BenchSeederTest extends TestCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider rejected_db_names
|
||||||
|
*/
|
||||||
|
public function test_rejects_non_bench_databases( string $name ): void {
|
||||||
|
$this->expectException( InvalidArgumentException::class );
|
||||||
|
TMDO_Bench_Seeder::assert_bench_db( $name );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function rejected_db_names(): array {
|
||||||
|
return array(
|
||||||
|
'production site' => array( 'wp_2meet_pro_dev21' ),
|
||||||
|
'integration db' => array( 'wp_wpdo_test' ),
|
||||||
|
'bench not suffix' => array( 'wp_bench_prod' ),
|
||||||
|
'empty' => array( '' ),
|
||||||
|
'injection' => array( 'x_bench`; DROP DATABASE y; -- _bench' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_accepts_bench_database(): void {
|
||||||
|
TMDO_Bench_Seeder::assert_bench_db( 'wp_wpdo_bench' );
|
||||||
|
$this->addToAssertionCount( 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_count_bounds(): void {
|
||||||
|
TMDO_Bench_Seeder::assert_count( 'listings', TMDO_Bench_Seeder::MAX_ROWS );
|
||||||
|
$this->addToAssertionCount( 1 );
|
||||||
|
|
||||||
|
foreach ( array( 0, -5, TMDO_Bench_Seeder::MAX_ROWS + 1 ) as $bad ) {
|
||||||
|
try {
|
||||||
|
TMDO_Bench_Seeder::assert_count( 'listings', $bad );
|
||||||
|
$this->fail( "Accepted out-of-range count {$bad}" );
|
||||||
|
} catch ( InvalidArgumentException $e ) {
|
||||||
|
$this->addToAssertionCount( 1 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_generators_are_deterministic_per_seed(): void {
|
||||||
|
$a = new TMDO_Bench_Seeder( null, 'wp_itest_', 100, 1000, 42 );
|
||||||
|
$b = new TMDO_Bench_Seeder( null, 'wp_itest_', 100, 1000, 42 );
|
||||||
|
$c = new TMDO_Bench_Seeder( null, 'wp_itest_', 100, 1000, 7 );
|
||||||
|
|
||||||
|
$this->assertSame( $a->listing_meta( 123, 5 ), $b->listing_meta( 123, 5 ) );
|
||||||
|
$this->assertSame( $a->vendor_meta( 9 ), $b->vendor_meta( 9 ) );
|
||||||
|
$this->assertNotSame( $a->listing_meta( 123, 5 ), $c->listing_meta( 123, 5 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_listing_distribution_matches_spec(): void {
|
||||||
|
$seeder = new TMDO_Bench_Seeder( null, 'wp_itest_', 1000, 10000 );
|
||||||
|
$statuses = array();
|
||||||
|
$featured = 0;
|
||||||
|
|
||||||
|
for ( $i = 1; $i <= 10000; $i++ ) {
|
||||||
|
$vendor = $seeder->listing_vendor( $i );
|
||||||
|
$this->assertGreaterThanOrEqual( 1, $vendor );
|
||||||
|
$this->assertLessThanOrEqual( 1000, $vendor );
|
||||||
|
|
||||||
|
$meta = $seeder->listing_meta( $i, $vendor );
|
||||||
|
$statuses[ $meta['hp_status'] ] = ( $statuses[ $meta['hp_status'] ] ?? 0 ) + 1;
|
||||||
|
$featured += $meta['hp_featured'];
|
||||||
|
$this->assertSame( (bool) $meta['hp_featured'], isset( $meta['hp_featured_time'] ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort( $statuses );
|
||||||
|
$this->assertSame( array( 'draft', 'pending', 'private', 'publish' ), array_keys( $statuses ) );
|
||||||
|
$this->assertEqualsWithDelta( 9000, $statuses['publish'], 300 );
|
||||||
|
$this->assertEqualsWithDelta( 500, $featured, 100 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,40 @@ class CoreBoundaryTest extends TestCase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TMDO_Core::run() fires at plugins_loaded:4, but these AddOn classes load
|
||||||
|
* at plugins_loaded:6 and wire themselves up in their own bootstrap. Probing
|
||||||
|
* them from core boot can never hit, so core must not mention them at all.
|
||||||
|
* (The infocards AddOn loads at :3 and also calls its own register().)
|
||||||
|
*/
|
||||||
|
public function test_core_boot_does_not_probe_addons_that_self_register(): void {
|
||||||
|
$tokens = token_get_all( (string) file_get_contents( dirname( __DIR__, 2 ) . '/includes/class-tmdo-core.php' ) );
|
||||||
|
$code = '';
|
||||||
|
foreach ( $tokens as $token ) {
|
||||||
|
if ( is_array( $token ) && in_array( $token[0], array( T_COMMENT, T_DOC_COMMENT ), true ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$code .= is_array( $token ) ? $token[1] : $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$late_addon_names = array(
|
||||||
|
'TMDO_Infocards',
|
||||||
|
'TMDO_Bookings',
|
||||||
|
'TMDO_Quotation',
|
||||||
|
'TMDO_Mobile_Bridge',
|
||||||
|
'TMDO_Collab',
|
||||||
|
'TMDO_Playlist',
|
||||||
|
'TMDO_WooCommerce',
|
||||||
|
'TMDO_WC_Orders_Interceptor',
|
||||||
|
'TMDO_Hivepress_Term_Comment_Fields',
|
||||||
|
'TMDO_Hivepress_Transient_Filter',
|
||||||
|
'register_hivepress_defaults',
|
||||||
|
);
|
||||||
|
$found = array_values( array_filter( $late_addon_names, static fn( $name ) => str_contains( $code, $name ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( array(), $found, 'Dead AddOn probes remain in TMDO_Core: ' . implode( ', ', $found ) );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove line comments and string literals so mentions inside them are ignored.
|
* Remove line comments and string literals so mentions inside them are ignored.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 8 AddOns for in-house 2meet plugins are deprecated: each partner plugin
|
||||||
|
* registers its own tables and fields with the TMDO_Anti_EAV_Aware trait
|
||||||
|
* (docs/PARTNER-REGISTRATION.md). Admins running one get told so.
|
||||||
|
*/
|
||||||
|
class DeprecatedAddonsNoticeTest extends TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
if ( ! class_exists( 'TMDO_Deprecated_Addons_Notice' ) ) {
|
||||||
|
require_once TMDO_PATH . 'admin/class-tmdo-deprecated-addons-notice.php';
|
||||||
|
}
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
|
||||||
|
$GLOBALS['_wp_is_multisite'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activate( string ...$slugs ): void {
|
||||||
|
$GLOBALS['_wp_options']['active_plugins'] = array_map(
|
||||||
|
static fn( $s ) => "2meet-data-optimizer-{$s}-addon/2meet-data-optimizer-{$s}-addon.php",
|
||||||
|
$slugs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_lists_only_active_deprecated_addons(): void {
|
||||||
|
$this->activate( 'spoke', 'hivepress', 'bookings' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'bookings', 'spoke' ), TMDO_Deprecated_Addons_Notice::active() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_notice_names_the_addons_and_points_to_the_guide(): void {
|
||||||
|
$this->activate( 'spoke' );
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
TMDO_Deprecated_Addons_Notice::render();
|
||||||
|
$html = (string) ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertStringContainsString( '2meet-data-optimizer-spoke-addon', $html );
|
||||||
|
$this->assertStringContainsString( 'PARTNER-REGISTRATION.md', $html );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sso_hub_and_spoke_addons_are_deprecated_without_replacement(): void {
|
||||||
|
$this->activate( 'hub', 'sso' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'hub', 'sso' ), TMDO_Deprecated_Addons_Notice::active() );
|
||||||
|
ob_start();
|
||||||
|
TMDO_Deprecated_Addons_Notice::render();
|
||||||
|
$html = (string) ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertStringContainsString( '2meet-data-optimizer-sso-addon', $html );
|
||||||
|
$this->assertStringContainsString( '不需替代', $html );
|
||||||
|
$this->assertStringContainsString( 'bridge-restore-native user --group=sso --retire', $html );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_no_notice_when_none_active_or_user_cannot_manage(): void {
|
||||||
|
ob_start();
|
||||||
|
TMDO_Deprecated_Addons_Notice::render();
|
||||||
|
$this->assertSame( '', ob_get_clean() );
|
||||||
|
|
||||||
|
$this->activate( 'spoke' );
|
||||||
|
$GLOBALS['_wp_current_user_can'] = array();
|
||||||
|
ob_start();
|
||||||
|
TMDO_Deprecated_Addons_Notice::render();
|
||||||
|
$this->assertSame( '', ob_get_clean() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_trait_passes_post_type_scope_to_the_registry(): void {
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||||
|
$partner = new class() {
|
||||||
|
use TMDO_Anti_EAV_Aware;
|
||||||
|
|
||||||
|
public function run(): bool {
|
||||||
|
return $this->register_entity_fields( 'post', 'x_item', array( array( 'key' => 'x_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'x_item' ) ) );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->assertTrue( $partner->run() );
|
||||||
|
$this->assertSame( array( 'x_item' ), TMDO_Entity_Registry::get_group_post_types( 'post', 'x_item' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity group name collisions.
|
||||||
|
*
|
||||||
|
* Two providers registering the same entity/group name used to have the
|
||||||
|
* second definition dropped with no trace. Identical re-registration (core
|
||||||
|
* re-fires the registration hooks on init:1) stays silent; a different
|
||||||
|
* definition is recorded with a field diff and surfaced in Site Health.
|
||||||
|
*/
|
||||||
|
class EntityRegistryCollisionTest extends TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
if ( ! class_exists( 'TMDO_Site_Health' ) ) {
|
||||||
|
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-site-health.php';
|
||||||
|
}
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function first_definition(): array {
|
||||||
|
return array(
|
||||||
|
array( 'key' => 'hp_price', 'type' => 'decimal', 'searchable' => true ),
|
||||||
|
array( 'key' => 'hp_featured', 'type' => 'boolean' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_identical_reregistration_is_silent(): void {
|
||||||
|
$this->assertTrue( TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() ) );
|
||||||
|
$this->assertFalse( TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() ) );
|
||||||
|
|
||||||
|
$this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_different_definition_is_recorded_with_field_diff(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() );
|
||||||
|
|
||||||
|
$second = array(
|
||||||
|
array( 'key' => 'hp_price', 'type' => 'integer', 'searchable' => true ),
|
||||||
|
array( 'key' => 'hp_vendor', 'type' => 'integer' ),
|
||||||
|
);
|
||||||
|
$this->assertFalse( TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $second ) );
|
||||||
|
|
||||||
|
$collisions = TMDO_Entity_Registry::get_collisions();
|
||||||
|
$this->assertCount( 1, $collisions );
|
||||||
|
$this->assertSame( 'post', $collisions[0]['entity_type'] );
|
||||||
|
$this->assertSame( 'hp_listing_core', $collisions[0]['group'] );
|
||||||
|
$this->assertSame( array( 'hp_vendor' ), $collisions[0]['added'] );
|
||||||
|
$this->assertSame( array( 'hp_featured' ), $collisions[0]['removed'] );
|
||||||
|
$this->assertSame( array( 'hp_price' ), $collisions[0]['changed'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_first_definition_wins(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() );
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', array( array( 'key' => 'hp_vendor', 'type' => 'integer' ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'hp_price', 'hp_featured' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_core' ) );
|
||||||
|
$this->assertNull( TMDO_Entity_Registry::get_field( 'post', 'hp_vendor' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_same_collision_is_recorded_once_per_request(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'g', $this->first_definition() );
|
||||||
|
$other = array( array( 'key' => 'x', 'type' => 'text' ) );
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'g', $other );
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'g', $other );
|
||||||
|
|
||||||
|
$this->assertCount( 1, TMDO_Entity_Registry::get_collisions() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_init_clears_collisions(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'g', $this->first_definition() );
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'g', array( array( 'key' => 'x', 'type' => 'text' ) ) );
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
|
||||||
|
$this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_site_health_reports_collisions(): void {
|
||||||
|
$this->assertSame( 'good', TMDO_Site_Health::check_entity_group_collisions()['status'] );
|
||||||
|
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() );
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', array( array( 'key' => 'x', 'type' => 'text' ) ) );
|
||||||
|
|
||||||
|
$result = TMDO_Site_Health::check_entity_group_collisions();
|
||||||
|
$this->assertSame( 'recommended', $result['status'] );
|
||||||
|
$this->assertStringContainsString( 'post/hp_listing_core', $result['description'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_site_health_registers_the_collision_test(): void {
|
||||||
|
$tests = TMDO_Site_Health::register_tests( array() );
|
||||||
|
$this->assertArrayHasKey( 'wpdo_entity_group_collisions', $tests['direct'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite indexes declared on entity groups.
|
||||||
|
*
|
||||||
|
* Declared through register_group()'s $args like post_types, so a group's
|
||||||
|
* field definition — and its persisted schema hash — never changes when an
|
||||||
|
* index is added.
|
||||||
|
*/
|
||||||
|
class EntityRegistryIndexesTest extends TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fields(): array {
|
||||||
|
return array(
|
||||||
|
array( 'key' => 'x_flag', 'type' => 'integer' ),
|
||||||
|
array( 'key' => 'x_price', 'type' => 'decimal' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_declared_indexes_are_kept_per_group(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'flag_price' => array( 'x_flag', 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
|
||||||
|
$this->assertSame( array(), TMDO_Entity_Registry::get_group_indexes( 'post', 'other' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_indexes_on_unknown_columns_are_dropped(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'bad' => array( 'x_flag', 'nope' ), 'ok' => array( 'x_price' ) ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'ok' => array( 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_indexes_leave_the_field_definition_and_hash_alone(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'plain', $this->fields() );
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'indexed', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) );
|
||||||
|
|
||||||
|
$plain = TMDO_Schema_Manager::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( 'post', 'plain' ) );
|
||||||
|
$indexed = TMDO_Schema_Manager::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( 'post', 'indexed' ) );
|
||||||
|
$this->assertSame( $plain, $indexed );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_init_clears_indexes(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'ok' => array( 'x_price' ) ) ) );
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
|
||||||
|
$this->assertSame( array(), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restoring orphaned entity groups from their persisted definition.
|
||||||
|
*
|
||||||
|
* When the plugin that registered a group is gone, its flat table keeps the
|
||||||
|
* data but nothing maps the meta keys to it any more — in aeav_only mode the
|
||||||
|
* values read back as empty. restore_group() re-registers the stored
|
||||||
|
* definition for read/write routing only: it must never queue DDL and must
|
||||||
|
* not take over keys another live group already manages.
|
||||||
|
*/
|
||||||
|
class EntityRegistryRestoreTest extends TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
if ( ! class_exists( 'TMDO_Site_Health' ) ) {
|
||||||
|
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-site-health.php';
|
||||||
|
}
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stored_definition(): array {
|
||||||
|
return array(
|
||||||
|
array( 'key' => 'orphan_note', 'type' => 'text', 'group' => 'orphan', 'entity_type' => 'user' ),
|
||||||
|
array( 'key' => 'orphan_score', 'type' => 'integer', 'searchable' => true, 'group' => 'orphan', 'entity_type' => 'user' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_restore_registers_fields_for_routing(): void {
|
||||||
|
$this->assertTrue( TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() ) );
|
||||||
|
|
||||||
|
$this->assertSame( 'orphan', TMDO_Entity_Registry::get_field( 'user', 'orphan_note' )['group'] );
|
||||||
|
$this->assertSame( array( 'orphan_note', 'orphan_score' ), TMDO_Entity_Registry::get_group_keys( 'user', 'orphan' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_restore_never_queues_ddl(): void {
|
||||||
|
TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() );
|
||||||
|
|
||||||
|
$this->assertSame( array(), TMDO_Entity_Registry::get_pending_schemas() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_restore_skips_keys_managed_by_a_live_group(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'user', 'live', array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) );
|
||||||
|
|
||||||
|
TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() );
|
||||||
|
|
||||||
|
$this->assertSame( 'live', TMDO_Entity_Registry::get_field( 'user', 'orphan_note' )['group'] );
|
||||||
|
$this->assertSame( array( 'orphan_score' ), TMDO_Entity_Registry::get_group_keys( 'user', 'orphan' ) );
|
||||||
|
$restored = TMDO_Entity_Registry::get_restored_groups();
|
||||||
|
$this->assertSame( array( 'orphan_note' ), $restored[0]['skipped'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_restore_leaves_keys_with_native_data_on_native_meta(): void {
|
||||||
|
TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition(), array( 'orphan_note' ) );
|
||||||
|
|
||||||
|
$this->assertNull( TMDO_Entity_Registry::get_field( 'user', 'orphan_note' ) );
|
||||||
|
$this->assertSame( array( 'orphan_score' ), TMDO_Entity_Registry::get_group_keys( 'user', 'orphan' ) );
|
||||||
|
$this->assertSame( array( 'orphan_note' ), TMDO_Entity_Registry::get_restored_groups()[0]['native'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_group_left_entirely_on_native_is_still_reported(): void {
|
||||||
|
$this->assertFalse( TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition(), array( 'orphan_note', 'orphan_score' ) ) );
|
||||||
|
|
||||||
|
$this->assertNotContains( 'orphan', TMDO_Entity_Registry::get_groups_for_type( 'user' ) );
|
||||||
|
$restored = TMDO_Entity_Registry::get_restored_groups();
|
||||||
|
$this->assertSame( 0, $restored[0]['fields'] );
|
||||||
|
$this->assertSame( array( 'orphan_note', 'orphan_score' ), $restored[0]['native'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_restore_is_refused_when_group_is_registered(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'user', 'orphan', array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) );
|
||||||
|
|
||||||
|
$this->assertFalse( TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() ) );
|
||||||
|
$this->assertSame( array(), TMDO_Entity_Registry::get_restored_groups() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_restore_is_refused_when_every_key_is_taken(): void {
|
||||||
|
TMDO_Entity_Registry::register_group(
|
||||||
|
'user',
|
||||||
|
'live',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'orphan_note', 'type' => 'text' ),
|
||||||
|
array( 'key' => 'orphan_score', 'type' => 'integer' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertFalse( TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() ) );
|
||||||
|
$this->assertNotContains( 'orphan', TMDO_Entity_Registry::get_groups_for_type( 'user' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_restored_groups_are_listed_and_cleared_by_init(): void {
|
||||||
|
TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() );
|
||||||
|
|
||||||
|
$restored = TMDO_Entity_Registry::get_restored_groups();
|
||||||
|
$this->assertSame( 'user', $restored[0]['entity_type'] );
|
||||||
|
$this->assertSame( 'orphan', $restored[0]['group'] );
|
||||||
|
$this->assertSame( 2, $restored[0]['fields'] );
|
||||||
|
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
$this->assertSame( array(), TMDO_Entity_Registry::get_restored_groups() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_site_health_reports_restored_groups(): void {
|
||||||
|
$this->assertSame( 'good', TMDO_Site_Health::check_orphaned_entity_groups()['status'] );
|
||||||
|
|
||||||
|
TMDO_Entity_Registry::restore_group( 'user', 'orphan', $this->stored_definition() );
|
||||||
|
|
||||||
|
$result = TMDO_Site_Health::check_orphaned_entity_groups();
|
||||||
|
$this->assertSame( 'recommended', $result['status'] );
|
||||||
|
$this->assertStringContainsString( 'user/orphan', $result['description'] );
|
||||||
|
$this->assertArrayHasKey( 'wpdo_orphaned_entity_groups', TMDO_Site_Health::register_tests( array() )['direct'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post entity groups scoped to post types.
|
||||||
|
*
|
||||||
|
* The post entity used to keep one meta_key index for every post type, so a
|
||||||
|
* key shared by several groups (hp_rating in hp_listing_core and
|
||||||
|
* hp_vendor_core) was routed to whichever group registered last — a
|
||||||
|
* listing's hp_rating landed in the vendor table. Groups can now declare the
|
||||||
|
* post types they belong to, and lookups that pass a post type use it.
|
||||||
|
*/
|
||||||
|
class EntityRegistryScopeTest extends TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||||
|
$GLOBALS['_wp_options'] = array( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION => '1' );
|
||||||
|
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'site_core', array( array( 'key' => '_edit_lock', 'type' => 'text' ) ) );
|
||||||
|
TMDO_Entity_Registry::register_group(
|
||||||
|
'post',
|
||||||
|
'listing_core',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'rating', 'type' => 'decimal' ),
|
||||||
|
array( 'key' => 'price', 'type' => 'decimal' ),
|
||||||
|
),
|
||||||
|
array( 'post_types' => array( 'hp_listing' ) )
|
||||||
|
);
|
||||||
|
TMDO_Entity_Registry::register_group(
|
||||||
|
'post',
|
||||||
|
'vendor_core',
|
||||||
|
array( array( 'key' => 'rating', 'type' => 'decimal' ) ),
|
||||||
|
array( 'post_types' => array( 'hp_vendor' ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shared_key_resolves_to_the_group_of_the_post_type(): void {
|
||||||
|
$this->assertSame( 'listing_core', TMDO_Entity_Registry::get_field( 'post', 'rating', 'hp_listing' )['group'] );
|
||||||
|
$this->assertSame( 'vendor_core', TMDO_Entity_Registry::get_field( 'post', 'rating', 'hp_vendor' )['group'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scoped_key_is_unmanaged_for_other_post_types(): void {
|
||||||
|
$this->assertNull( TMDO_Entity_Registry::get_field( 'post', 'rating', 'post' ) );
|
||||||
|
$this->assertNull( TMDO_Entity_Registry::get_field( 'post', 'price', 'hp_vendor' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unscoped_group_applies_to_every_post_type(): void {
|
||||||
|
$this->assertSame( 'site_core', TMDO_Entity_Registry::get_field( 'post', '_edit_lock', 'hp_listing' )['group'] );
|
||||||
|
$this->assertSame( 'site_core', TMDO_Entity_Registry::get_field( 'post', '_edit_lock', 'page' )['group'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_lookup_without_post_type_keeps_legacy_last_wins(): void {
|
||||||
|
$this->assertSame( 'vendor_core', TMDO_Entity_Registry::get_field( 'post', 'rating' )['group'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scoping_is_off_until_the_option_enables_it(): void {
|
||||||
|
$GLOBALS['_wp_options'][ TMDO_Entity_Registry::SCOPED_ROUTING_OPTION ] = '0';
|
||||||
|
|
||||||
|
$this->assertSame( 'vendor_core', TMDO_Entity_Registry::get_field( 'post', 'rating', 'hp_listing' )['group'] );
|
||||||
|
$this->assertSame( 'listing_core', TMDO_Entity_Registry::get_field( 'post', 'price', 'hp_vendor' )['group'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scope_is_not_part_of_the_field_definition(): void {
|
||||||
|
// Keeps schema hashes and PR-017 collision checks unchanged.
|
||||||
|
$fields = TMDO_Entity_Registry::get_group_fields( 'post', 'vendor_core' );
|
||||||
|
$this->assertArrayNotHasKey( 'post_types', $fields[0] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scoped_routing_default_waits_for_reconcile_on_sites_with_post_flat_data(): void {
|
||||||
|
// Existing flat rows may sit in the wrong group table (legacy routing);
|
||||||
|
// switching lookups before they are reconciled would hide them.
|
||||||
|
$this->assertSame( '0', TMDO_Installer::post_scoped_routing_default( 'aeav_only', true ) );
|
||||||
|
$this->assertSame( '0', TMDO_Installer::post_scoped_routing_default( 'dual_write', true ) );
|
||||||
|
$this->assertSame( '1', TMDO_Installer::post_scoped_routing_default( 'aeav_only', false ) );
|
||||||
|
$this->assertSame( '1', TMDO_Installer::post_scoped_routing_default( 'disabled', true ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,9 +18,9 @@ class HookBusBridgeTest extends TestCase {
|
|||||||
|
|
||||||
// ── is_enabled() ────────────────────────────────────────────────────────
|
// ── is_enabled() ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function test_is_enabled_default_true(): void {
|
public function test_is_enabled_default_false(): void {
|
||||||
// v2.5.4: Hook Bus is ON by default; option not set → true.
|
// Hook Bus is opt-in; option not set → false. Existing sites store '1' explicitly.
|
||||||
$this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() );
|
$this->assertFalse( WPDO_Hook_Bus_Bridge::is_enabled() );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_is_enabled_when_option_set_to_string_one(): void {
|
public function test_is_enabled_when_option_set_to_string_one(): void {
|
||||||
|
|||||||
@@ -39,12 +39,24 @@ class MemberFieldsRegistrationTest extends TestCase {
|
|||||||
public function test_all_four_groups_are_registered(): void {
|
public function test_all_four_groups_are_registered(): void {
|
||||||
WPDO_Member_Fields::register_entity_fields();
|
WPDO_Member_Fields::register_entity_fields();
|
||||||
|
|
||||||
foreach ( array( 'membership', 'activity', 'profile', 'sso' ) as $group ) {
|
foreach ( array( 'membership', 'activity', 'profile' ) as $group ) {
|
||||||
$fields = WPDO_Entity_Registry::get_group_fields( 'user', $group );
|
$fields = WPDO_Entity_Registry::get_group_fields( 'user', $group );
|
||||||
$this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." );
|
$this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_core_leaves_the_sso_group_to_the_sso_addon(): void {
|
||||||
|
WPDO_Member_Fields::register_entity_fields();
|
||||||
|
|
||||||
|
$this->assertSame( array(), WPDO_Entity_Registry::get_group_fields( 'user', 'sso' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_core_registers_no_hp_user_group(): void {
|
||||||
|
WPDO_Member_Fields::register_entity_fields();
|
||||||
|
|
||||||
|
$this->assertSame( array(), WPDO_Entity_Registry::get_group_fields( 'user', 'hp_user' ), 'hp_user is owned by the HivePress AddOn' );
|
||||||
|
}
|
||||||
|
|
||||||
// ── membership group ─────────────────────────────────────────────────────
|
// ── membership group ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function test_membership_group_has_six_fields(): void {
|
public function test_membership_group_has_six_fields(): void {
|
||||||
@@ -142,41 +154,6 @@ class MemberFieldsRegistrationTest extends TestCase {
|
|||||||
$this->assertTrue( (bool) $field['fulltext'] );
|
$this->assertTrue( (bool) $field['fulltext'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── sso group ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function test_sso_group_has_eight_fields(): void {
|
|
||||||
WPDO_Member_Fields::register_entity_fields();
|
|
||||||
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
|
|
||||||
// v3.4.4: known_login_ips registered (was leaking to wp_usermeta via set_entity).
|
|
||||||
$this->assertCount( 8, $fields );
|
|
||||||
$this->assertNotNull( $this->find_field( $fields, 'known_login_ips' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_hub_global_user_id_is_searchable(): void {
|
|
||||||
WPDO_Member_Fields::register_entity_fields();
|
|
||||||
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
|
|
||||||
$field = $this->find_field( $fields, 'hub_global_user_id' );
|
|
||||||
|
|
||||||
$this->assertTrue( (bool) $field['searchable'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_token_expires_at_is_datetime_searchable(): void {
|
|
||||||
WPDO_Member_Fields::register_entity_fields();
|
|
||||||
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
|
|
||||||
$field = $this->find_field( $fields, 'token_expires_at' );
|
|
||||||
|
|
||||||
$this->assertSame( 'datetime', $field['type'] );
|
|
||||||
$this->assertTrue( (bool) $field['searchable'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_refresh_token_enc_is_textarea(): void {
|
|
||||||
WPDO_Member_Fields::register_entity_fields();
|
|
||||||
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
|
|
||||||
$field = $this->find_field( $fields, 'refresh_token_enc' );
|
|
||||||
|
|
||||||
$this->assertSame( 'textarea', $field['type'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── register() hooks add_action ──────────────────────────────────────────
|
// ── register() hooks add_action ──────────────────────────────────────────
|
||||||
|
|
||||||
public function test_register_hooks_wpdo_register_entity_fields(): void {
|
public function test_register_hooks_wpdo_register_entity_fields(): void {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HivePress / WooCommerce running without their AddOn.
|
||||||
|
*
|
||||||
|
* Core knows nothing about either plugin: without the AddOn their fields are
|
||||||
|
* not optimised (and aeav_only data only survives through the orphan-group
|
||||||
|
* restore). Admins are warned in Site Health and with an admin notice.
|
||||||
|
*
|
||||||
|
* Tests that declare HivePress / WooCommerce / AddOn stand-in classes run in
|
||||||
|
* their own process so those classes do not leak into other tests.
|
||||||
|
*/
|
||||||
|
class MissingIntegrationAddonsTest extends TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
if ( ! class_exists( 'TMDO_Site_Health' ) ) {
|
||||||
|
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-site-health.php';
|
||||||
|
}
|
||||||
|
if ( ! class_exists( 'TMDO_Missing_Addons_Notice' ) ) {
|
||||||
|
require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php';
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'admin_url' ) ) {
|
||||||
|
// Site Health failure envelopes link to the admin page; a fresh process lacks the stub.
|
||||||
|
eval( 'function admin_url( $path = "" ) { return "/wp-admin/" . $path; }' );
|
||||||
|
}
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nothing_missing_without_the_integrations(): void {
|
||||||
|
$this->assertSame( array(), TMDO_Compatibility::missing_integration_addons() );
|
||||||
|
$this->assertSame( 'good', TMDO_Site_Health::check_missing_integration_addons()['status'] );
|
||||||
|
$this->assertArrayHasKey( 'wpdo_missing_integration_addons', TMDO_Site_Health::register_tests( array() )['direct'] );
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
TMDO_Missing_Addons_Notice::render();
|
||||||
|
$this->assertSame( '', ob_get_clean() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @runInSeparateProcess
|
||||||
|
* @preserveGlobalState disabled
|
||||||
|
*/
|
||||||
|
public function test_hivepress_without_its_addon_is_reported(): void {
|
||||||
|
eval( 'namespace HivePress; class Core {}' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'HivePress' => '2meet-data-optimizer-hivepress-addon' ), TMDO_Compatibility::missing_integration_addons() );
|
||||||
|
|
||||||
|
$result = TMDO_Site_Health::check_missing_integration_addons();
|
||||||
|
$this->assertSame( 'recommended', $result['status'] );
|
||||||
|
$this->assertStringContainsString( '2meet-data-optimizer-hivepress-addon', $result['description'] );
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
TMDO_Missing_Addons_Notice::render();
|
||||||
|
$this->assertStringContainsString( '2meet-data-optimizer-hivepress-addon', (string) ob_get_clean() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @runInSeparateProcess
|
||||||
|
* @preserveGlobalState disabled
|
||||||
|
*/
|
||||||
|
public function test_hivepress_with_its_addon_is_fine(): void {
|
||||||
|
eval( 'namespace HivePress; class Core {}' );
|
||||||
|
eval( 'final class TMDO_HP_Bootstrap {}' );
|
||||||
|
|
||||||
|
$this->assertSame( array(), TMDO_Compatibility::missing_integration_addons() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @runInSeparateProcess
|
||||||
|
* @preserveGlobalState disabled
|
||||||
|
*/
|
||||||
|
public function test_woocommerce_without_its_addon_is_reported(): void {
|
||||||
|
eval( 'class WooCommerce {}' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'WooCommerce' => '2meet-data-optimizer-woocommerce-addon' ), TMDO_Compatibility::missing_integration_addons() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @runInSeparateProcess
|
||||||
|
* @preserveGlobalState disabled
|
||||||
|
*/
|
||||||
|
public function test_hub_and_spoke_need_no_addon(): void {
|
||||||
|
eval( 'final class TMHC_Plugin {}' );
|
||||||
|
eval( 'final class TMSO_Plugin {}' );
|
||||||
|
|
||||||
|
$this->assertSame( array(), TMDO_Compatibility::missing_integration_addons() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @runInSeparateProcess
|
||||||
|
* @preserveGlobalState disabled
|
||||||
|
*/
|
||||||
|
public function test_notice_is_for_administrators_only(): void {
|
||||||
|
eval( 'class WooCommerce {}' );
|
||||||
|
$GLOBALS['_wp_current_user_can'] = array();
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
TMDO_Missing_Addons_Notice::render();
|
||||||
|
$this->assertSame( '', ob_get_clean() );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit test: TMDO_Mode_Manager defaults.
|
||||||
|
*
|
||||||
|
* A fresh install must not route any entity through the flat tables until an
|
||||||
|
* admin opts in; stored modes (e.g. aeav_only on live sites) must be kept as-is.
|
||||||
|
*/
|
||||||
|
class ModeManagerDefaultsTest extends TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_all_entities_default_to_disabled(): void {
|
||||||
|
$this->assertSame(
|
||||||
|
array(
|
||||||
|
'post' => TMDO_Mode_Manager::MODE_DISABLED,
|
||||||
|
'user' => TMDO_Mode_Manager::MODE_DISABLED,
|
||||||
|
'term' => TMDO_Mode_Manager::MODE_DISABLED,
|
||||||
|
'comment' => TMDO_Mode_Manager::MODE_DISABLED,
|
||||||
|
),
|
||||||
|
TMDO_Mode_Manager::all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stored_modes_are_kept(): void {
|
||||||
|
update_option(
|
||||||
|
'wpdo_bridge_modes',
|
||||||
|
array(
|
||||||
|
'post' => 'aeav_only',
|
||||||
|
'user' => 'dual_write',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$modes = TMDO_Mode_Manager::all();
|
||||||
|
|
||||||
|
$this->assertSame( 'aeav_only', $modes['post'] );
|
||||||
|
$this->assertSame( 'dual_write', $modes['user'] );
|
||||||
|
$this->assertSame( TMDO_Mode_Manager::MODE_DISABLED, $modes['term'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core routing code resolves group ↔ post_type / table from the registry.
|
||||||
|
*
|
||||||
|
* HivePress groups are registered by the HivePress AddOn with their post
|
||||||
|
* types; core must not hardcode their names. The HivePress-only legacy
|
||||||
|
* cutover tooling (wpdo_hot_hp_listing → hp_listing_core) and the stress
|
||||||
|
* testers are out of scope and not checked here.
|
||||||
|
*/
|
||||||
|
class NoHpMappingsTest extends TestCase {
|
||||||
|
|
||||||
|
private const FILES = array(
|
||||||
|
'includes/class-tmdo-post-shadow-verifier.php',
|
||||||
|
'includes/migration/class-tmdo-post-migration.php',
|
||||||
|
'includes/query/class-tmdo-post-query-router.php',
|
||||||
|
'includes/class-tmdo-term-comment-backfill.php',
|
||||||
|
'includes/class-tmdo-term-comment-shadow-verifier.php',
|
||||||
|
'cli/class-tmdo-cli-term-comment.php',
|
||||||
|
'includes/class-tmdo-installer.php',
|
||||||
|
'includes/class-tmdo-rest-api.php',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Stress-test tooling inside the REST API class keeps its sample post types. */
|
||||||
|
private const ALLOWED = array(
|
||||||
|
'includes/class-tmdo-rest-api.php' => array( "array( 'product', 'hp_listing', 'hp_request', 'hp_vendor', 'attachment', 'nav_menu_item', 'post' )" ),
|
||||||
|
);
|
||||||
|
|
||||||
|
public function test_routing_code_has_no_hivepress_names(): void {
|
||||||
|
$root = dirname( __DIR__, 2 );
|
||||||
|
$offences = array();
|
||||||
|
foreach ( self::FILES as $rel ) {
|
||||||
|
$code = '';
|
||||||
|
foreach ( token_get_all( (string) file_get_contents( $root . '/' . $rel ) ) as $token ) {
|
||||||
|
if ( is_array( $token ) && in_array( $token[0], array( T_COMMENT, T_DOC_COMMENT ), true ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$code .= is_array( $token ) ? $token[1] : $token;
|
||||||
|
}
|
||||||
|
$code = str_replace( self::ALLOWED[ $rel ] ?? array(), '', $code );
|
||||||
|
if ( preg_match_all( '/hp_(listing|request|vendor|taxonomy|review|user)\w*/', $code, $m ) ) {
|
||||||
|
$offences[] = $rel . ': ' . implode( ', ', array_unique( $m[0] ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame( array(), $offences, "Hardcoded HivePress names in core routing:\n" . implode( "\n", $offences ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_term_comment_groups_come_from_the_registry(): void {
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'term', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'comment', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||||
|
TMDO_Entity_Registry::register_group( 'term', 'x_tax', array( array( 'key' => 'x_order', 'type' => 'integer' ) ) );
|
||||||
|
TMDO_Entity_Registry::register_group( 'comment', 'x_review', array( array( 'key' => 'x_rating', 'type' => 'integer' ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'x_tax' => 'term', 'x_review' => 'comment' ), TMDO_Term_Comment_Backfill::registered_groups() );
|
||||||
|
|
||||||
|
$result = TMDO_Term_Comment_Backfill::backfill_group( 'term', 'hp_taxonomy', true );
|
||||||
|
$this->assertSame( 'Unknown group: hp_taxonomy', $result['error'] ?? '', 'unregistered groups are unknown' );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core carries no HPCT (HivePress Custom Tables) legacy.
|
||||||
|
*
|
||||||
|
* The HPCT interceptors were removed from the HivePress AddOn (PR-014) and no
|
||||||
|
* site ever ran the HPCT import, so the modules, compatibility probes, admin
|
||||||
|
* tab and CLI command that fed them are gone too. The only survivor is the
|
||||||
|
* `wpdo_hpct_imported` option name in the uninstall cleanup list, so old
|
||||||
|
* installs still get it removed.
|
||||||
|
*/
|
||||||
|
class NoHpctLegacyTest extends TestCase {
|
||||||
|
|
||||||
|
private const ALLOWED = array(
|
||||||
|
'includes/class-tmdo-installer.php' => array( 'wpdo_hpct_imported' ),
|
||||||
|
);
|
||||||
|
|
||||||
|
public function test_core_code_does_not_mention_hpct(): void {
|
||||||
|
$root = dirname( __DIR__, 2 );
|
||||||
|
$offences = array();
|
||||||
|
foreach ( array( 'includes', 'cli', 'admin' ) as $dir ) {
|
||||||
|
$files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root . '/' . $dir, FilesystemIterator::SKIP_DOTS ) );
|
||||||
|
foreach ( $files as $file ) {
|
||||||
|
if ( 'php' !== $file->getExtension() ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rel = substr( $file->getPathname(), strlen( $root ) + 1 );
|
||||||
|
$code = '';
|
||||||
|
foreach ( token_get_all( (string) file_get_contents( $file->getPathname() ) ) as $token ) {
|
||||||
|
if ( is_array( $token ) && in_array( $token[0], array( T_COMMENT, T_DOC_COMMENT ), true ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$code .= is_array( $token ) ? $token[1] : $token;
|
||||||
|
}
|
||||||
|
$code = str_replace( self::ALLOWED[ $rel ] ?? array(), '', $code );
|
||||||
|
if ( preg_match_all( '/\w*hpct\w*/i', $code, $m ) ) {
|
||||||
|
$offences[] = $rel . ': ' . implode( ', ', array_unique( $m[0] ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame( array(), $offences, "HPCT legacy left in core:\n" . implode( "\n", $offences ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_only_live_addon_modules_remain_besides_zone_modules(): void {
|
||||||
|
$this->assertSame( array( 'wc_orders', 'latepoint' ), TMDO_Feature_Flags::ADDON_MODULES );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$ref = new ReflectionProperty( TMDO_Feature_Flags::class, 'cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, null );
|
||||||
|
|
||||||
|
$modules = array_keys( TMDO_Feature_Flags::all() );
|
||||||
|
foreach ( array( 'reviews', 'messages', 'favorites', 'memberships', 'statistics', 'requests', 'listing_meta' ) as $gone ) {
|
||||||
|
$this->assertNotContains( $gone, $modules );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for TMDO_Options_Manager 的受保護選項守衛。
|
||||||
|
*
|
||||||
|
* 背景:register_settings_group() 會以 pre_update_option_{key} 回傳 $old_value,
|
||||||
|
* 讓選項不再落地 wp_options、改存專屬設定表。但 NinjaFirewall Full WAF 走
|
||||||
|
* auto_prepend_file,在 WordPress 載入前就以原生 mysqli 直查 wp_options 取
|
||||||
|
* nfw_options / nfw_rules。若這些鍵被重導向,WAF 會讀不到設定而靜默停止防護
|
||||||
|
* (不報錯、不寫 log),因此必須在註冊階段就擋下。
|
||||||
|
*
|
||||||
|
* 註:混合「受保護 + 一般」鍵的情境需要走到 dbDelta(),而單元測試 bootstrap
|
||||||
|
* 沒有該 stub(ABSPATH 為假路徑),故此處只覆蓋純受保護鍵與常數契約 —— 這已
|
||||||
|
* 足以鎖住「受保護鍵絕不會被掛上攔截 filter」這個核心保證。
|
||||||
|
*/
|
||||||
|
class OptionsManagerProtectedTest extends TestCase {
|
||||||
|
|
||||||
|
/** 每個測試前清空靜態註冊表,避免跨測試污染。 */
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$prop = new ReflectionProperty( 'TMDO_Options_Manager', 'redirected_options' );
|
||||||
|
$prop->setAccessible( true );
|
||||||
|
$prop->setValue( null, array() );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 常數契約 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_protected_options_covers_ninjafirewall_keys(): void {
|
||||||
|
$ref = new ReflectionClass( 'TMDO_Options_Manager' );
|
||||||
|
$protected = $ref->getConstant( 'PROTECTED_OPTIONS' );
|
||||||
|
|
||||||
|
$this->assertIsArray( $protected );
|
||||||
|
foreach ( array( 'nfw_options', 'nfw_rules', 'nfw_checked' ) as $key ) {
|
||||||
|
$this->assertContains(
|
||||||
|
$key,
|
||||||
|
$protected,
|
||||||
|
"{$key} 必須列入 PROTECTED_OPTIONS,否則重導向後 NinjaFirewall Full WAF 會靜默失效"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 註冊守衛 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_protected_options_are_not_registered(): void {
|
||||||
|
TMDO_Options_Manager::register_settings_group(
|
||||||
|
'waf_guard_test',
|
||||||
|
array( 'nfw_options', 'nfw_rules', 'nfw_checked' )
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
array(),
|
||||||
|
TMDO_Options_Manager::get_redirected_options(),
|
||||||
|
'受保護的選項不得進入重導向清單'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_protected_options_get_no_interception_filters(): void {
|
||||||
|
TMDO_Options_Manager::register_settings_group(
|
||||||
|
'waf_guard_test',
|
||||||
|
array( 'nfw_options', 'nfw_rules' )
|
||||||
|
);
|
||||||
|
|
||||||
|
$hooks = $GLOBALS['_wp_filter_callbacks'] ?? array();
|
||||||
|
|
||||||
|
foreach ( array( 'nfw_options', 'nfw_rules' ) as $key ) {
|
||||||
|
$this->assertArrayNotHasKey(
|
||||||
|
"pre_option_{$key}",
|
||||||
|
$hooks,
|
||||||
|
"pre_option_{$key} 不得被掛上 —— 會讓 WAF 讀到轉址後的值"
|
||||||
|
);
|
||||||
|
$this->assertArrayNotHasKey(
|
||||||
|
"pre_update_option_{$key}",
|
||||||
|
$hooks,
|
||||||
|
"pre_update_option_{$key} 不得被掛上 —— 會讓設定不再寫回 wp_options"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全部鍵都被擋下時應提前返回,不建立設定表。 */
|
||||||
|
public function test_all_protected_keys_skips_table_creation(): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$before = $wpdb->last_query ?? null;
|
||||||
|
|
||||||
|
TMDO_Options_Manager::register_settings_group(
|
||||||
|
'waf_guard_test',
|
||||||
|
array( 'nfw_options' )
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$before,
|
||||||
|
$wpdb->last_query ?? null,
|
||||||
|
'不應對資料庫發出任何查詢'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase;
|
|||||||
/**
|
/**
|
||||||
* Unit tests for WPDO_Post_Fields entity group registration (v2.9.1).
|
* Unit tests for WPDO_Post_Fields entity group registration (v2.9.1).
|
||||||
*
|
*
|
||||||
* Verifies that register_entity_fields() correctly registers all seven
|
* Verifies that register_entity_fields() correctly registers all four
|
||||||
* post groups with the expected field counts, types, and post_type targets.
|
* post groups with the expected field counts, types, and post_type targets.
|
||||||
*
|
*
|
||||||
* Mirrors MemberFieldsRegistrationTest's structure.
|
* Mirrors MemberFieldsRegistrationTest's structure.
|
||||||
@@ -33,16 +33,13 @@ class PostFieldsRegistrationTest extends TestCase {
|
|||||||
$this->assertTrue( true );
|
$this->assertTrue( true );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_all_seven_groups_are_registered(): void {
|
public function test_all_four_groups_are_registered(): void {
|
||||||
WPDO_Post_Fields::register_entity_fields();
|
WPDO_Post_Fields::register_entity_fields();
|
||||||
|
|
||||||
$expected = array(
|
$expected = array(
|
||||||
'wp_core',
|
'wp_core',
|
||||||
'attachment',
|
'attachment',
|
||||||
'wc_product',
|
'wc_product',
|
||||||
'hp_listing_core',
|
|
||||||
'hp_request_core',
|
|
||||||
'hp_vendor_core',
|
|
||||||
'nav_menu_item',
|
'nav_menu_item',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,22 +126,31 @@ class PostFieldsRegistrationTest extends TestCase {
|
|||||||
$this->assertContains( 'outofstock', $field['options'] );
|
$this->assertContains( 'outofstock', $field['options'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── hp_listing_core group ────────────────────────────────────────────────
|
// ── HivePress groups moved to the HivePress AddOn ───────────────────────
|
||||||
|
|
||||||
public function test_hp_listing_core_critical_keys_present(): void {
|
public function test_core_registers_no_hivepress_groups(): void {
|
||||||
WPDO_Post_Fields::register_entity_fields();
|
WPDO_Post_Fields::register_entity_fields();
|
||||||
$keys = $this->get_field_keys( 'hp_listing_core' );
|
foreach ( array( 'hp_listing_core', 'hp_request_core', 'hp_vendor_core' ) as $group ) {
|
||||||
foreach ( array( 'hp_price', 'hp_status', 'hp_featured', 'hp_verified', 'hp_vendor' ) as $key ) {
|
$this->assertSame( array(), WPDO_Entity_Registry::get_group_fields( 'post', $group ), "{$group} is owned by the HivePress AddOn" );
|
||||||
$this->assertContains( $key, $keys, "hp_listing_core missing critical key: {$key}" );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_hp_listing_price_is_searchable_decimal(): void {
|
// ── group ↔ post_type mapping ────────────────────────────────────────────
|
||||||
WPDO_Post_Fields::register_entity_fields();
|
|
||||||
$field = $this->find_field( 'hp_listing_core', 'hp_price' );
|
public function test_core_groups_map_to_their_post_types(): void {
|
||||||
$this->assertNotNull( $field );
|
$this->assertSame( 'attachment', WPDO_Post_Fields::post_type_for_group( 'attachment' ) );
|
||||||
$this->assertSame( 'decimal', $field['type'] );
|
$this->assertSame( 'product', WPDO_Post_Fields::post_type_for_group( 'wc_product' ) );
|
||||||
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
|
$this->assertSame( 'nav_menu_item', WPDO_Post_Fields::post_type_for_group( 'nav_menu_item' ) );
|
||||||
|
$this->assertNull( WPDO_Post_Fields::post_type_for_group( 'wp_core' ), 'wp_core spans every post type' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scoped_groups_map_through_the_registry(): void {
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'x_core', array( array( 'key' => 'x_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'x_type' ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( 'x_type', WPDO_Post_Fields::post_type_for_group( 'x_core' ) );
|
||||||
|
$this->assertSame( array( 'x_core' ), WPDO_Post_Fields::groups_for_post_type( 'x_type' ) );
|
||||||
|
$this->assertSame( array( 'wc_product' ), WPDO_Post_Fields::groups_for_post_type( 'product' ) );
|
||||||
|
$this->assertNull( WPDO_Post_Fields::post_type_for_group( 'hp_listing_core' ), 'unregistered HivePress group is unknown to core' );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── nav_menu_item group ──────────────────────────────────────────────────
|
// ── nav_menu_item group ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which values the reconciler has to move.
|
||||||
|
*
|
||||||
|
* Under legacy routing a key shared by several post groups was read and
|
||||||
|
* written through the last group registered. Once lookups follow the post
|
||||||
|
* type, each scoped group must first receive the values its posts stored in
|
||||||
|
* that legacy owner's table.
|
||||||
|
*/
|
||||||
|
class PostGroupReconcilerPlanTest extends TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_plan_lists_shared_keys_whose_legacy_owner_is_another_group(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'listing', array( array( 'key' => 'shared', 'type' => 'text' ), array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'vendor', array( array( 'key' => 'shared', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) );
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
array(
|
||||||
|
array(
|
||||||
|
'target' => 'listing',
|
||||||
|
'source' => 'vendor',
|
||||||
|
'key' => 'shared',
|
||||||
|
'post_types' => array( 'hp_listing' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TMDO_Post_Group_Reconciler::plan()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unscoped_groups_and_unshared_keys_need_no_move(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'site', array( array( 'key' => '_edit_lock', 'type' => 'text' ) ) );
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'listing', array( array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( array(), TMDO_Post_Group_Reconciler::plan() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_registry_exposes_group_post_types(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'listing', array( array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'site', array( array( 'key' => '_edit_lock', 'type' => 'text' ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'hp_listing' ), TMDO_Entity_Registry::get_group_post_types( 'post', 'listing' ) );
|
||||||
|
$this->assertSame( array(), TMDO_Entity_Registry::get_group_post_types( 'post', 'site' ) );
|
||||||
|
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
$this->assertSame( array(), TMDO_Entity_Registry::get_group_post_types( 'post', 'listing' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ class RestApiTest extends TestCase {
|
|||||||
$GLOBALS['_wp_post_types'] = [];
|
$GLOBALS['_wp_post_types'] = [];
|
||||||
$GLOBALS['_wp_current_user_can'] = [];
|
$GLOBALS['_wp_current_user_can'] = [];
|
||||||
$GLOBALS['_wp_valid_nonces'] = [];
|
$GLOBALS['_wp_valid_nonces'] = [];
|
||||||
|
$GLOBALS['_wp_query_posts'] = [];
|
||||||
|
$GLOBALS['_wp_registered_post_types'] = [ 'hp_listing' => true ];
|
||||||
$_COOKIE = [];
|
$_COOKIE = [];
|
||||||
|
|
||||||
// Reset Feature Flags cache via reflection.
|
// Reset Feature Flags cache via reflection.
|
||||||
@@ -153,6 +155,53 @@ class RestApiTest extends TestCase {
|
|||||||
$this->assertIsArray( $response->get_data() );
|
$this->assertIsArray( $response->get_data() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_default_post_type_is_post_and_filterable(): void {
|
||||||
|
$this->assertSame( 'post', WPDO_REST_API::default_post_type() );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_filter_callbacks']['tmdo_rest_default_post_type'][] = static fn() => 'hp_listing';
|
||||||
|
try {
|
||||||
|
$this->assertSame( 'hp_listing', WPDO_REST_API::default_post_type() );
|
||||||
|
} finally {
|
||||||
|
unset( $GLOBALS['_wp_filter_callbacks']['tmdo_rest_default_post_type'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_listings_rejects_unregistered_post_type(): void {
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'no_such_type' );
|
||||||
|
|
||||||
|
$response = $this->api->get_listings( $req );
|
||||||
|
$this->assertSame( 400, $response->get_status() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_listings_rejects_non_viewable_post_type(): void {
|
||||||
|
$GLOBALS['_wp_registered_post_types']['hp_internal'] = false;
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'hp_internal' );
|
||||||
|
|
||||||
|
$response = $this->api->get_listings( $req );
|
||||||
|
$this->assertSame( 400, $response->get_status() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_listings_fallback_only_returns_show_in_rest_columns(): void {
|
||||||
|
$registry = WPDO_Schema_Registry::instance();
|
||||||
|
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price' ) );
|
||||||
|
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_secret', 'zone' => 'hot', 'column' => 'hp_secret', 'show_in_rest' => false ) );
|
||||||
|
|
||||||
|
$post = new stdClass();
|
||||||
|
$post->ID = 21;
|
||||||
|
$post->post_type = 'hp_listing';
|
||||||
|
$GLOBALS['_wp_query_posts'] = array( $post );
|
||||||
|
$GLOBALS['_wp_postmeta'][21] = array( 'hp_price' => '99', 'hp_secret' => 'hidden' );
|
||||||
|
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'hp_listing' );
|
||||||
|
|
||||||
|
$data = $this->api->get_listings( $req )->get_data();
|
||||||
|
$this->assertSame( '99', $data[0]['hp_price'] );
|
||||||
|
$this->assertArrayNotHasKey( 'hp_secret', $data[0] );
|
||||||
|
}
|
||||||
|
|
||||||
// ── Pagination headers ────────────────────────────────────────────────────
|
// ── Pagination headers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function test_listings_fallback_sets_pagination_headers(): void {
|
public function test_listings_fallback_sets_pagination_headers(): void {
|
||||||
|
|||||||
@@ -18,6 +18,30 @@ class SchemaRegistryTest extends TestCase {
|
|||||||
$instance->setValue( null, null );
|
$instance->setValue( null, null );
|
||||||
|
|
||||||
$this->registry = WPDO_Schema_Registry::instance();
|
$this->registry = WPDO_Schema_Registry::instance();
|
||||||
|
$GLOBALS['_doing_it_wrong_calls'] = array();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hot zone deprecation (ADR-003) ──────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_hot_registration_is_flagged_once_per_provider(): void {
|
||||||
|
foreach ( array( 'p1', 'p1', 'p2' ) as $i => $provider ) {
|
||||||
|
$this->registry->register( $provider, array( 'post_type' => 'x', 'meta_key' => "k{$i}", 'zone' => 'hot' ) );
|
||||||
|
}
|
||||||
|
$this->registry->register( 'p1', array( 'post_type' => 'x', 'meta_key' => 'k9' ) ); // zone defaults to hot.
|
||||||
|
|
||||||
|
$calls = $GLOBALS['_doing_it_wrong_calls'];
|
||||||
|
$this->assertCount( 2, $calls );
|
||||||
|
$this->assertStringContainsString( 'p1', $calls[0][1] );
|
||||||
|
$this->assertStringContainsString( 'p2', $calls[1][1] );
|
||||||
|
$this->assertSame( 'hot', $this->registry->get_field_zone( 'x', 'k9' ), 'still registered: frozen, not rejected' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_other_zones_are_not_flagged(): void {
|
||||||
|
$this->registry->register( 'p', array( 'post_type' => 'x', 'meta_key' => 'c', 'zone' => 'cold' ) );
|
||||||
|
$this->registry->register( 'p', array( 'post_type' => 'x', 'meta_key' => 'w', 'zone' => 'warm' ) );
|
||||||
|
$this->registry->register( 'p', array( 'post_type' => 'x', 'meta_key' => 'a', 'zone' => 'archive' ) );
|
||||||
|
|
||||||
|
$this->assertSame( array(), $GLOBALS['_doing_it_wrong_calls'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── register() ──────────────────────────────────────────────────────────
|
// ── register() ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
+62
-171
@@ -5,14 +5,18 @@
|
|||||||
* Runs only when the plugin is deleted via the WordPress admin.
|
* Runs only when the plugin is deleted via the WordPress admin.
|
||||||
* Removes all WPDO tables and options. Does NOT remove HPCT tables.
|
* Removes all WPDO tables and options. Does NOT remove HPCT tables.
|
||||||
*
|
*
|
||||||
|
* Refuses (wp_die, before any snapshot or DROP) while any site still holds data
|
||||||
|
* that exists only in wpdo_* tables — see TMDO_Installer::uninstall_blockers().
|
||||||
|
* wp_die() also stops WordPress from deleting the plugin files.
|
||||||
|
*
|
||||||
* v2.2.0 hardening: take a `pre_uninstall` snapshot BEFORE dropping tables.
|
* v2.2.0 hardening: take a `pre_uninstall` snapshot BEFORE dropping tables.
|
||||||
* The snapshot file under wp-content/uploads/wpdo-backups/ survives plugin
|
* The snapshot file under wp-content/uploads/wpdo-backups/ survives plugin
|
||||||
* deletion (uploads dir is never auto-purged), so the user can re-install +
|
* deletion (uploads dir is never auto-purged), so the user can re-install +
|
||||||
* `wp wpdo snapshot restore <id> --apply` to recover.
|
* `wp wpdo snapshot restore <id> --apply` to recover.
|
||||||
*
|
*
|
||||||
* v2.14.0 multisite hardening: when network-uninstalled, iterate all sites
|
* Multisite: every site is checked and cleaned. The plugin may have been active
|
||||||
* via switch_to_blog so every wp_N_wpdo_* table is dropped (previously only
|
* per site, and it is always inactive by the time it can be deleted, so network
|
||||||
* the main site got cleaned, leaving orphans on every other site).
|
* activation cannot tell which sites hold wpdo_* tables.
|
||||||
*
|
*
|
||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
@@ -44,11 +48,61 @@ foreach ( $bootstrap_files as $f ) {
|
|||||||
require_once $f;
|
require_once $f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sites to check and clean (0 = single site, no blog switching) ────────
|
||||||
|
$wpdo_site_ids = is_multisite()
|
||||||
|
? array_map(
|
||||||
|
'intval',
|
||||||
|
get_sites(
|
||||||
|
array(
|
||||||
|
'fields' => 'ids',
|
||||||
|
'number' => 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: array( 0 );
|
||||||
|
|
||||||
|
$wpdo_for_each_site = static function ( callable $callback ) use ( $wpdo_site_ids ): void {
|
||||||
|
foreach ( $wpdo_site_ids as $wpdo_blog_id ) {
|
||||||
|
if ( $wpdo_blog_id ) {
|
||||||
|
switch_to_blog( $wpdo_blog_id );
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$callback( $wpdo_blog_id );
|
||||||
|
} finally {
|
||||||
|
if ( $wpdo_blog_id ) {
|
||||||
|
restore_current_blog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Refuse while any site holds data that exists only in wpdo_* tables ───
|
||||||
|
if ( ! $bootstrap_ok ) {
|
||||||
|
wp_die( esc_html( '2meet Data Optimizer:無法載入資料安全檢查,為避免資料遺失,已停止解除安裝。' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$wpdo_blocked = array();
|
||||||
|
$wpdo_for_each_site(
|
||||||
|
static function ( int $blog_id ) use ( &$wpdo_blocked ): void {
|
||||||
|
foreach ( TMDO_Installer::uninstall_blockers() as $reason ) {
|
||||||
|
$wpdo_blocked[] = ( $blog_id ? "站台 #{$blog_id}:" : '' ) . $reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( $wpdo_blocked ) {
|
||||||
|
wp_die(
|
||||||
|
'<p>' . esc_html( '2meet Data Optimizer 已停止解除安裝:以下資料只存在 wpdo_* 表,刪除外掛會一併刪除這些表。' ) . '</p>'
|
||||||
|
. '<ul><li>' . implode( '</li><li>', array_map( 'esc_html', $wpdo_blocked ) ) . '</li></ul>'
|
||||||
|
. '<p>' . esc_html( '目前沒有把這些資料寫回 WordPress 原生表的自動工具。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '</p>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── v2.2.0 M1.5: pre-uninstall snapshot (main-site context only) ─────────
|
// ── v2.2.0 M1.5: pre-uninstall snapshot (main-site context only) ─────────
|
||||||
// Snapshot is taken once on the main site — it captures the catalog of all
|
// Snapshot is taken once on the main site — it captures the catalog of all
|
||||||
// wpdo_snapshots rows (which is per-site so per-site snapshots survive the
|
// wpdo_snapshots rows (which is per-site so per-site snapshots survive the
|
||||||
// site-level table DROP that happens later). Best-effort.
|
// site-level table DROP that happens later). Best-effort.
|
||||||
if ( $bootstrap_ok && class_exists( 'TMDO_Snapshot_Manager' ) ) {
|
if ( class_exists( 'TMDO_Snapshot_Manager' ) ) {
|
||||||
$result = TMDO_Snapshot_Manager::create(
|
$result = TMDO_Snapshot_Manager::create(
|
||||||
'pre_uninstall',
|
'pre_uninstall',
|
||||||
array(),
|
array(),
|
||||||
@@ -75,172 +129,9 @@ if ( $bootstrap_ok && class_exists( 'TMDO_Snapshot_Manager' ) ) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── v2.14.0: shared cleanup helper ───────────────────────────────────────
|
// ── Drop wpdo_* tables and options on every site ─────────────────────────
|
||||||
// Falls back to inline DROP loop if the helper class isn't loadable
|
$wpdo_for_each_site(
|
||||||
// (e.g. partially-broken install where includes/class-tmdo-installer.php
|
static function (): void {
|
||||||
// is missing). The inline path is the original v2.13.x logic verbatim.
|
|
||||||
$has_helper = class_exists( 'TMDO_Installer' )
|
|
||||||
&& method_exists( 'TMDO_Installer', 'drop_all_tables_for_current_blog' );
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the cleanup for the current blog (helper-aware).
|
|
||||||
*/
|
|
||||||
$run_cleanup = static function () use ( $has_helper ): void {
|
|
||||||
global $wpdb;
|
|
||||||
|
|
||||||
if ( $has_helper ) {
|
|
||||||
TMDO_Installer::drop_all_tables_for_current_blog();
|
TMDO_Installer::drop_all_tables_for_current_blog();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
// ── Fallback inline path (helper unavailable) ─────────────────────────
|
|
||||||
$tables = array(
|
|
||||||
$wpdb->prefix . 'wpdo_migrations',
|
|
||||||
$wpdb->prefix . 'wpdo_errors',
|
|
||||||
$wpdb->prefix . 'wpdo_benchmarks',
|
|
||||||
$wpdb->prefix . 'wpdo_warm',
|
|
||||||
$wpdb->prefix . 'wpdo_archive',
|
|
||||||
$wpdb->prefix . 'wpdo_audit',
|
|
||||||
$wpdb->prefix . 'wpdo_shadow_diffs',
|
|
||||||
$wpdb->prefix . 'wpdo_site_metrics',
|
|
||||||
$wpdb->prefix . 'wpdo_registry_meta',
|
|
||||||
$wpdb->prefix . 'wpdo_uni_options',
|
|
||||||
$wpdb->prefix . 'wpdo_wc_commissions',
|
|
||||||
$wpdb->prefix . 'wpdo_snapshots',
|
|
||||||
$wpdb->prefix . 'wpdo_migration_status',
|
|
||||||
$wpdb->prefix . 'wpdo_user_points_ledger',
|
|
||||||
$wpdb->prefix . 'wpdo_user_membership',
|
|
||||||
$wpdb->prefix . 'wpdo_user_activity',
|
|
||||||
$wpdb->prefix . 'wpdo_user_profile',
|
|
||||||
$wpdb->prefix . 'wpdo_user_sso',
|
|
||||||
$wpdb->prefix . 'wpdo_user_core_profile',
|
|
||||||
$wpdb->prefix . 'wpdo_user_social',
|
|
||||||
$wpdb->prefix . 'wpdo_user_commerce',
|
|
||||||
$wpdb->prefix . 'wpdo_user_hp_user',
|
|
||||||
$wpdb->prefix . 'wpdo_user_admin_prefs',
|
|
||||||
$wpdb->prefix . 'wpdo_post_wp_core',
|
|
||||||
$wpdb->prefix . 'wpdo_post_attachment',
|
|
||||||
$wpdb->prefix . 'wpdo_post_wc_product',
|
|
||||||
$wpdb->prefix . 'wpdo_post_hp_listing_core',
|
|
||||||
$wpdb->prefix . 'wpdo_post_hp_request_core',
|
|
||||||
$wpdb->prefix . 'wpdo_post_hp_vendor_core',
|
|
||||||
$wpdb->prefix . 'wpdo_post_nav_menu_item',
|
|
||||||
$wpdb->prefix . 'wpdo_hot_hp_listing',
|
|
||||||
$wpdb->prefix . 'wpdo_term_hp_taxonomy',
|
|
||||||
$wpdb->prefix . 'wpdo_comment_hp_review',
|
|
||||||
$wpdb->prefix . 'wpdo_term_misc',
|
|
||||||
$wpdb->prefix . 'wpdo_comment_misc',
|
|
||||||
// v3.0.0: HivePress family integration tables.
|
|
||||||
$wpdb->prefix . 'wpdo_hot_hp_request',
|
|
||||||
$wpdb->prefix . 'wpdo_hot_hp_membership',
|
|
||||||
$wpdb->prefix . 'wpdo_hot_hp_membership_plan',
|
|
||||||
$wpdb->prefix . 'wpdo_hot_hp_booking',
|
|
||||||
$wpdb->prefix . 'wpdo_comment_hp_message',
|
|
||||||
$wpdb->prefix . 'wpdo_comment_hp_favorite',
|
|
||||||
$wpdb->prefix . 'wpdo_comment_hp_offer',
|
|
||||||
$wpdb->prefix . 'wpdo_term_hp_listing_tag',
|
|
||||||
);
|
|
||||||
|
|
||||||
$hot_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_hot_' ) . '%';
|
|
||||||
$cold_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_cold_' ) . '%';
|
|
||||||
$entity_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_user_' ) . '%';
|
|
||||||
$post_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%';
|
|
||||||
$term_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_term_' ) . '%';
|
|
||||||
$comment_ent_like = $wpdb->esc_like( $wpdb->prefix . 'wpdo_comment_' ) . '%';
|
|
||||||
|
|
||||||
if ( class_exists( 'WP_SQLite_Driver' ) ) {
|
|
||||||
$dynamic = $wpdb->get_col(
|
|
||||||
$wpdb->prepare(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s OR name LIKE %s)",
|
|
||||||
$hot_like,
|
|
||||||
$cold_like,
|
|
||||||
$entity_like,
|
|
||||||
$post_ent_like,
|
|
||||||
$term_ent_like,
|
|
||||||
$comment_ent_like
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$dynamic = $wpdb->get_col(
|
|
||||||
$wpdb->prepare(
|
|
||||||
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND (TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s)',
|
|
||||||
$hot_like,
|
|
||||||
$cold_like,
|
|
||||||
$entity_like,
|
|
||||||
$post_ent_like,
|
|
||||||
$term_ent_like,
|
|
||||||
$comment_ent_like
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tables = array_unique( array_merge( $tables, $dynamic ?: array() ) );
|
|
||||||
$prefix = $wpdb->prefix . 'wpdo_';
|
|
||||||
foreach ( $tables as $table ) {
|
|
||||||
if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) || strpos( $table, $prefix ) !== 0 ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
||||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
|
||||||
}
|
|
||||||
|
|
||||||
delete_option( 'wpdo_db_version' );
|
|
||||||
delete_option( 'wpdo_features' );
|
|
||||||
delete_option( 'wpdo_features_shadow' );
|
|
||||||
delete_option( 'wpdo_hpct_imported' );
|
|
||||||
delete_option( 'wpdo_hook_bus_enabled' );
|
|
||||||
delete_option( 'wpdo_v2_features_backup' );
|
|
||||||
delete_option( 'wpdo_v2_upgrade_status' );
|
|
||||||
delete_option( 'wpdo_v2_upgrade_error' );
|
|
||||||
delete_option( 'wpdo_v2_upgraded_at' );
|
|
||||||
delete_option( 'wpdo_health_alert' );
|
|
||||||
delete_option( 'wpdo_rl_stats' );
|
|
||||||
delete_option( 'wpdo_setup_wizard_completed' );
|
|
||||||
delete_option( 'wpdo_first_run_at' );
|
|
||||||
|
|
||||||
wp_clear_scheduled_hook( 'wpdo_warm_cleanup' );
|
|
||||||
wp_clear_scheduled_hook( 'wpdo_archive_sweep' );
|
|
||||||
wp_clear_scheduled_hook( 'wpdo_errors_gc' );
|
|
||||||
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
|
|
||||||
wp_clear_scheduled_hook( 'wpdo_daily_health_check' );
|
|
||||||
wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' );
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Run cleanup ──────────────────────────────────────────────────────────
|
|
||||||
// v2.14.0: when network-uninstalling on multisite, iterate every site so each
|
|
||||||
// wp_N_wpdo_* table set is dropped. Pre-v2.14.0 only the main site got cleaned.
|
|
||||||
if ( is_multisite() ) {
|
|
||||||
$is_network_active = function_exists( 'is_plugin_active_for_network' )
|
|
||||||
&& is_plugin_active_for_network( plugin_basename( __FILE__ ) );
|
|
||||||
|
|
||||||
if ( $is_network_active ) {
|
|
||||||
// Iterate all sites in batches.
|
|
||||||
$offset = 0;
|
|
||||||
$batch = 100;
|
|
||||||
do {
|
|
||||||
$site_ids = get_sites(
|
|
||||||
array(
|
|
||||||
'number' => $batch,
|
|
||||||
'offset' => $offset,
|
|
||||||
'fields' => 'ids',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
foreach ( $site_ids as $wpdo_blog_id ) {
|
|
||||||
switch_to_blog( (int) $wpdo_blog_id );
|
|
||||||
try {
|
|
||||||
$run_cleanup();
|
|
||||||
} finally {
|
|
||||||
restore_current_blog();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$offset += $batch;
|
|
||||||
$batch_size = count( $site_ids );
|
|
||||||
} while ( $batch_size === $batch );
|
|
||||||
} else {
|
|
||||||
// Per-site activation: clean only the current site.
|
|
||||||
$run_cleanup();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Single-site install: run once.
|
|
||||||
$run_cleanup();
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user