From 9f587c39dc8c0514db70082adf3f8924a00b3e39 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 8 Aug 2026 14:27:15 +0800 Subject: [PATCH 01/30] =?UTF-8?q?fix:=20Schema=20Registry=20cold=20zone=20?= =?UTF-8?q?=E8=A8=BB=E5=86=8A=E6=94=B9=E7=82=BA=E5=86=AA=E7=AD=89=EF=BC=88?= =?UTF-8?q?=E4=BF=AE=E5=BE=A9=E6=AC=84=E4=BD=8D=E9=87=8D=E8=A4=87=E7=B4=AF?= =?UTF-8?q?=E7=A9=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三個 zone 索引的寫入方式並不一致: - hot -> hot_columns[post_type][column] 以 column 為 key,天生冪等 - warm -> warm_fields[meta_key] 以 meta_key 為 key,天生冪等 - cold -> cold_fields[post_type][] append,無去重 而 wpdo_register_fields 本來就會被觸發多次:核心自身於 plugins_loaded:4 觸發,主檔另有 late-bind safety net 於 :30 再觸發一次(讓在 :6 之後才 bootstrap 的 AddOn 趕上);加上主外掛與其 AddOn 可能同時註冊同一批欄位。 實測影響:某站台 cold_fields['hp_vendor'] 累積到 35 筆但只有 9 個相異值, 每個 key 重複 4 次。修正後為 9 筆、零重複。 修法:cold 分支加入 in_array() 去重,使 register() 對同一 (post_type, meta_key) 成為冪等操作,與 hot / warm 既有行為一致。 驗證:核心單元測試 587 tests / 1156 assertions 全綠; wp wpdo doctor 於兩個站台皆 0 FAIL。 Co-Authored-By: Claude Opus 5 (1M context) --- 2meet-data-optimizer.php | 4 ++-- CHANGELOG.md | 32 +++++++++++++++++++++++++ includes/class-tmdo-schema-registry.php | 11 ++++++++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index 5fa0c00..a843da7 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -3,7 +3,7 @@ * Plugin Name: 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。 - * Version: 1.0.0 + * Version: 1.0.1 * Requires at least: 6.0 * Tested up to: 6.9.4 * Requires PHP: 8.1 @@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // ── Constants ────────────────────────────────────────────────────────────── -define( 'TMDO_VERSION', '1.0.0' ); +define( 'TMDO_VERSION', '1.0.1' ); define( 'TMDO_DB_VERSION', '2.1.0' ); define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_URL', plugin_dir_url( __FILE__ ) ); diff --git a/CHANGELOG.md b/CHANGELOG.md index 67351ed..16e2121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [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 本版把 `wp-data-optimizer` v3.0.1–v3.4.6(92 個 commit)全數 backport 進來, diff --git a/includes/class-tmdo-schema-registry.php b/includes/class-tmdo-schema-registry.php index 44ff201..45ef898 100644 --- a/includes/class-tmdo-schema-registry.php +++ b/includes/class-tmdo-schema-registry.php @@ -145,7 +145,16 @@ class TMDO_Schema_Registry { break; 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; } } -- 2.43.0 From 9fa84845bedd1d777fbe64ec73534120e22bef0e Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 15 Aug 2026 20:38:09 +0800 Subject: [PATCH 02/30] =?UTF-8?q?fix:=20NinjaFirewall=20=E7=9B=B8=E5=AE=B9?= =?UTF-8?q?=E6=80=A7=20=E2=80=94=20=E4=BF=9D=E8=AD=B7=20WAF=20=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E9=81=B8=E9=A0=85=E4=B8=8D=E8=A2=AB=E9=87=8D=E5=B0=8E?= =?UTF-8?q?=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 評估 NinjaFirewall (WP Edition) 4.9 的相容性,結論是兩者可共存且 不需要開發 AddOn(該外掛全 codebase 零個 apply_filters('nfw_*') / do_action('nfw_*'),官方相容手段全在部署層)。但核心有一個隱患必須補。 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。 nfw_rules 約 77KB 且 autoload=auto,正是 autoload 瘦身最誘人的目標, 因此這條路徑並非理論風險。 Added - PROTECTED_OPTIONS 常數與註冊守衛(nfw_options / nfw_rules / nfw_checked), 命中時發出 _doing_it_wrong()。守衛置於方法開頭,全部鍵都被擋時提前返回, 不再建立空的設定表。 - tests/unit/OptionsManagerProtectedTest.php(4 tests / 10 assertions), 鎖住「受保護鍵絕不會被掛上 pre_option_* / pre_update_option_* 攔截」。 - docs/WAF-COMPATIBILITY.md:模式差異、symlink 多租戶部署、WP SaaS 開站 流程與驗證清單、三條開發約束、實測風險矩陣。 Changed - Migration Wizard 輪詢 500ms → 2s,與四個 stress-test 面板一致。 原本 2 req/s 打同一 REST endpoint,易觸發 WAF rate-limit 與 bot 偵測。 autoload 最佳化不受影響:optimize_autoload() 只改 autoload 欄位、不刪列, 而 Full WAF 的 SELECT * 不看 autoload。 驗證:591 tests / 1166 assertions 通過,PHPCS 零違規,版本一致性 1.0.2。 dev30 於 Full WAF 與 WP WAF 兩種模式下實測,firewall log 中 TMDO 相關 攔截 0 筆。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SdjXAU473eekjPBB8vPVRS --- 2meet-data-optimizer.php | 4 +- CHANGELOG.md | 43 +++ PLAN.md | 27 +- admin/assets/wpdo-migration-wizard.js | 4 +- docs/WAF-COMPATIBILITY.md | 279 ++++++++++++++++++ .../options/class-tmdo-options-manager.php | 32 ++ tests/unit/OptionsManagerProtectedTest.php | 100 +++++++ 7 files changed, 484 insertions(+), 5 deletions(-) create mode 100644 docs/WAF-COMPATIBILITY.md create mode 100644 tests/unit/OptionsManagerProtectedTest.php diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index a843da7..653c501 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -3,7 +3,7 @@ * Plugin Name: 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。 - * Version: 1.0.1 + * Version: 1.0.2 * Requires at least: 6.0 * Tested up to: 6.9.4 * Requires PHP: 8.1 @@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // ── Constants ────────────────────────────────────────────────────────────── -define( 'TMDO_VERSION', '1.0.1' ); +define( 'TMDO_VERSION', '1.0.2' ); define( 'TMDO_DB_VERSION', '2.1.0' ); define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_URL', plugin_dir_url( __FILE__ ) ); diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e2121..2aa3a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,49 @@ 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 變更,無破壞性變更。 diff --git a/PLAN.md b/PLAN.md index c5092b3..3cb3e9d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,7 +6,7 @@ ## 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 已完成 - **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.1` — Phase 5 follow-up(tests / PHPCS / packaging) - `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 驗證。 diff --git a/admin/assets/wpdo-migration-wizard.js b/admin/assets/wpdo-migration-wizard.js index 109e4c4..2b2665f 100644 --- a/admin/assets/wpdo-migration-wizard.js +++ b/admin/assets/wpdo-migration-wizard.js @@ -3,7 +3,7 @@ * * - Confirms options + checkbox before starting. * - 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. * * @since 2.8.0 @@ -11,7 +11,7 @@ (function () { 'use strict'; - const POLL_INTERVAL_MS = 500; + const POLL_INTERVAL_MS = 2000; const config = window.wpdoMigrationWizard || {}; const i18n = config.i18n || {}; const restUrl = (config.restUrl || '').replace(/\/$/, ''); diff --git a/docs/WAF-COMPATIBILITY.md b/docs/WAF-COMPATIBILITY.md new file mode 100644 index 0000000..f4cba3c --- /dev/null +++ b/docs/WAF-COMPATIBILITY.md @@ -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 +`)不會外洩內容,但不該依賴它。 + +### 3.2 每個新租戶站台(順序不可顛倒) + +**順序很重要**:先確保錨點與設定就緒,再安裝 Full WAF。順序顛倒會直接撞上 §2 的 `#3` 錯誤。 + +**Step 1 — 啟用外掛,建立該站自己的設定** + +```bash +wp --path=/var/www/sites/ plugin activate ninjafirewall +wp --path=/var/www/sites/ 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//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://.example.com +NFWLOG=/var/www/sites//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/` 若回 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 的著力點。 diff --git a/modules/options/class-tmdo-options-manager.php b/modules/options/class-tmdo-options-manager.php index a84ecb4..7eee3e4 100644 --- a/modules/options/class-tmdo-options-manager.php +++ b/modules/options/class-tmdo-options-manager.php @@ -46,6 +46,22 @@ final class TMDO_Options_Manager { '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),超過則警告 */ 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 { 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 ); // 建立設定專屬表 diff --git a/tests/unit/OptionsManagerProtectedTest.php b/tests/unit/OptionsManagerProtectedTest.php new file mode 100644 index 0000000..cb740ff --- /dev/null +++ b/tests/unit/OptionsManagerProtectedTest.php @@ -0,0 +1,100 @@ +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, + '不應對資料庫發出任何查詢' + ); + } +} -- 2.43.0 From 8fc1dc1f1c27169445f5246f8ab3829b57209185 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 20:55:25 +0800 Subject: [PATCH 03/30] test(2meet-data-optimizer): add scale seeder for 100k vendors / 1M listings - Seed a HivePress-shaped dataset three ways (native meta, entity tables, hot table) with flat tables built by TMDO's own schema code - Refuse any database not named *_bench before the bootstrap connects; bootstrap now honours a pre-defined ABSPATH for the wp-admin stub - Full scale seeds 9.5M postmeta rows in 7m25s; 0 value mismatches Verify: TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/seed.php --vendors=100000 --listings=1000000; unit 600 OK, integration 416 OK PR: PR-004 --- tests/bench/class-tmdo-bench-seeder.php | 372 ++++++++++++++++++ tests/bench/seed.php | 51 +++ .../wp-stub/wp-admin/includes/upgrade.php | 9 + tests/integration/bootstrap.php | 3 +- tests/unit/BenchSeederTest.php | 84 ++++ 5 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 tests/bench/class-tmdo-bench-seeder.php create mode 100644 tests/bench/seed.php create mode 100644 tests/bench/wp-stub/wp-admin/includes/upgrade.php create mode 100644 tests/unit/BenchSeederTest.php diff --git a/tests/bench/class-tmdo-bench-seeder.php b/tests/bench/class-tmdo-bench-seeder.php new file mode 100644 index 0000000..3ea707a --- /dev/null +++ b/tests/bench/class-tmdo-bench-seeder.php @@ -0,0 +1,372 @@ + '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 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 + */ + 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 */ + 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 */ + 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 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 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 $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 $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 ) ); + } + } +} diff --git a/tests/bench/seed.php b/tests/bench/seed.php new file mode 100644 index 0000000..a00bfa0 --- /dev/null +++ b/tests/bench/seed.php @@ -0,0 +1,51 @@ +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 ); +} diff --git a/tests/bench/wp-stub/wp-admin/includes/upgrade.php b/tests/bench/wp-stub/wp-admin/includes/upgrade.php new file mode 100644 index 0000000..774e3c4 --- /dev/null +++ b/tests/bench/wp-stub/wp-admin/includes/upgrade.php @@ -0,0 +1,9 @@ +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 ); + } +} -- 2.43.0 From d3b72bd1afa9ed3e3a1f006e4be1e7459316d7aa Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 21:29:58 +0800 Subject: [PATCH 04/30] test(2meet-data-optimizer): add scale benchmark report (native vs hot vs entity) - Benchmark 7 scenarios at 100k vendors / 1M listings with SQL shaped like WP_Query, TMDO_Query_Router and TMDO_Post_Query_Router - Native meta_query takes 14-17s; flat tables bring searches to 0.3-0.6s - Hot table lacks an hp_vendor index (3.4s); entity lacks composite indexes Verify: TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/run.php --iterations=20 PR: PR-005 --- docs/BENCHMARK-SCALE.md | 70 +++++++++++ tests/bench/run.php | 249 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 docs/BENCHMARK-SCALE.md create mode 100644 tests/bench/run.php diff --git a/docs/BENCHMARK-SCALE.md b/docs/BENCHMARK-SCALE.md new file mode 100644 index 0000000..2097d4a --- /dev/null +++ b/docs/BENCHMARK-SCALE.md @@ -0,0 +1,70 @@ +# 規模效能基準:原生 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 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。 +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)。 +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 並減少一次寫入 | diff --git a/tests/bench/run.php b/tests/bench/run.php new file mode 100644 index 0000000..07d7c99 --- /dev/null +++ b/tests/bench/run.php @@ -0,0 +1,249 @@ +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"; +} + +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()' ); + }, + ), + '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()' ); + }, + ), + '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 + ); + } +} -- 2.43.0 From 151aadd6618e4c5b2d5ab862476f8b014c8066a2 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 21:39:38 +0800 Subject: [PATCH 05/30] fix(2meet-data-optimizer): default misc bucket and garbage filter to off - Both term/comment filters now default to disabled for new installs, so activating the plugin no longer reroutes or drops other plugins' meta - One-time maybe_upgrade() migration keeps the misc bucket on for sites that already hold misc rows and never set the toggle (data stays readable) - Admin toggles show the same defaults as the filters Verify: phpunit-integration --filter 'defaults_false|preserve_default'; unit 600 OK, integration 420 OK PR: PR-006 --- admin/class-tmdo-admin.php | 4 +- includes/class-tmdo-installer.php | 31 ++++++++++++ ...class-tmdo-term-comment-garbage-filter.php | 4 +- .../class-tmdo-term-comment-misc-bucket.php | 5 +- .../TermCommentGarbageFilterTest.php | 4 +- .../integration/TermCommentMiscBucketTest.php | 50 ++++++++++++++++++- 6 files changed, 89 insertions(+), 9 deletions(-) diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php index 9f11387..e4fbc65 100644 --- a/admin/class-tmdo-admin.php +++ b/admin/class-tmdo-admin.php @@ -3490,7 +3490,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' console.log(r));' 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 ); } // ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ───────────────── diff --git a/includes/integrations/class-tmdo-term-comment-garbage-filter.php b/includes/integrations/class-tmdo-term-comment-garbage-filter.php index 75b2b52..1056a68 100644 --- a/includes/integrations/class-tmdo-term-comment-garbage-filter.php +++ b/includes/integrations/class-tmdo-term-comment-garbage-filter.php @@ -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 */ public static function is_enabled(): bool { - return (bool) get_option( self::OPT_ENABLED, '1' ); + return (bool) get_option( self::OPT_ENABLED, '0' ); } /** diff --git a/includes/integrations/class-tmdo-term-comment-misc-bucket.php b/includes/integrations/class-tmdo-term-comment-misc-bucket.php index 7bc52bb..80c5a55 100644 --- a/includes/integrations/class-tmdo-term-comment-misc-bucket.php +++ b/includes/integrations/class-tmdo-term-comment-misc-bucket.php @@ -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 */ public static function is_enabled(): bool { - return (bool) get_option( self::OPT_ENABLED, '1' ); + return (bool) get_option( self::OPT_ENABLED, '0' ); } /** diff --git a/tests/integration/TermCommentGarbageFilterTest.php b/tests/integration/TermCommentGarbageFilterTest.php index 36a69b0..2fd5d21 100644 --- a/tests/integration/TermCommentGarbageFilterTest.php +++ b/tests/integration/TermCommentGarbageFilterTest.php @@ -152,9 +152,9 @@ class TermCommentGarbageFilterTest extends TestCase { // ── 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 ); - $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 { diff --git a/tests/integration/TermCommentMiscBucketTest.php b/tests/integration/TermCommentMiscBucketTest.php index 2bca15c..28b0c31 100644 --- a/tests/integration/TermCommentMiscBucketTest.php +++ b/tests/integration/TermCommentMiscBucketTest.php @@ -189,11 +189,59 @@ class TermCommentMiscBucketTest extends TestCase { // ── 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 ); + $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() ); } + 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 { update_option( WPDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '0' ); $this->assertFalse( WPDO_Term_Comment_Misc_Bucket::is_enabled() ); -- 2.43.0 From fc0f018a948358d5035f020235f0eb8792eed654 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 21:48:23 +0800 Subject: [PATCH 06/30] fix(2meet-data-optimizer): stop auto-activating entity bridge on install - install() no longer switches the Hook Bus on or puts user/term/comment into dual_write; the now-unused maybe_autoactivate_entity_bridge() is gone - Hook Bus and Mode_Manager defaults are off/disabled; sites with stored modes (incl. aeav_only) keep them since only unset keys use defaults - Guard run_dbdelta()'s upgrade.php require like install_v2_tables() so install() can run under the integration test stub Verify: InstallerV2Test::test_install_does_not_enable_entity_bridge, ModeManagerDefaultsTest; unit 602 OK, integration 421 OK PR: PR-007 --- includes/class-tmdo-hook-bus-bridge.php | 2 +- includes/class-tmdo-installer.php | 40 ++--------------- includes/engine/class-tmdo-mode-manager.php | 11 +++-- tests/integration/InstallerV2Test.php | 13 ++++++ tests/integration/bootstrap.php | 1 + tests/unit/HookBusBridgeTest.php | 6 +-- tests/unit/ModeManagerDefaultsTest.php | 50 +++++++++++++++++++++ 7 files changed, 77 insertions(+), 46 deletions(-) create mode 100644 tests/unit/ModeManagerDefaultsTest.php diff --git a/includes/class-tmdo-hook-bus-bridge.php b/includes/class-tmdo-hook-bus-bridge.php index 02517bc..6436db4 100644 --- a/includes/class-tmdo-hook-bus-bridge.php +++ b/includes/class-tmdo-hook-bus-bridge.php @@ -54,7 +54,7 @@ final class TMDO_Hook_Bus_Bridge { if ( null !== 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 ); return self::$cache; } diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index 5e90c9c..b003c07 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -224,47 +224,12 @@ class TMDO_Installer { 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 GCM. Idempotent best-effort: skipped if no v1 ciphertext present // or if the migration flag is already set. 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). * @@ -1303,7 +1268,10 @@ class TMDO_Installer { */ private static function run_dbdelta(): void { 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(); $p = $wpdb->prefix; diff --git a/includes/engine/class-tmdo-mode-manager.php b/includes/engine/class-tmdo-mode-manager.php index 4498141..5fed59d 100644 --- a/includes/engine/class-tmdo-mode-manager.php +++ b/includes/engine/class-tmdo-mode-manager.php @@ -65,16 +65,15 @@ final class TMDO_Mode_Manager { private static ?array $cache = null; /** - * 預設模式(v2.5.4 起): - * post → disabled(由 Legacy Feature_Flags FSM 管理,不由 Mode_Manager 控制) - * user / term / comment → dual_write(安全雙寫:寫入 flat table + 原生 EAV,讀取仍走 EAV) + * 預設模式:全部 disabled,由管理者明確選擇後才啟用 entity bridge。 + * 已存在 wpdo_bridge_modes 的站台(包含 aeav_only)以儲存值為準,不受影響。 */ private static function defaults(): array { return array( 'post' => self::MODE_DISABLED, - 'user' => self::MODE_DUAL_WRITE, - 'term' => self::MODE_DUAL_WRITE, - 'comment' => self::MODE_DUAL_WRITE, + 'user' => self::MODE_DISABLED, + 'term' => self::MODE_DISABLED, + 'comment' => self::MODE_DISABLED, ); } diff --git a/tests/integration/InstallerV2Test.php b/tests/integration/InstallerV2Test.php index 689597c..7fc5cfd 100644 --- a/tests/integration/InstallerV2Test.php +++ b/tests/integration/InstallerV2Test.php @@ -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 { // Leave v2 tables in place for subsequent tests / dev convenience. // Cleanup happens via wp wpdo cleanup-uae-tables --confirm in real upgrades. diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 9d6b059..4b3a0a3 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -302,6 +302,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( '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( '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( '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; } } diff --git a/tests/unit/HookBusBridgeTest.php b/tests/unit/HookBusBridgeTest.php index 129fd05..ebfa8ef 100644 --- a/tests/unit/HookBusBridgeTest.php +++ b/tests/unit/HookBusBridgeTest.php @@ -18,9 +18,9 @@ class HookBusBridgeTest extends TestCase { // ── is_enabled() ──────────────────────────────────────────────────────── - public function test_is_enabled_default_true(): void { - // v2.5.4: Hook Bus is ON by default; option not set → true. - $this->assertTrue( WPDO_Hook_Bus_Bridge::is_enabled() ); + public function test_is_enabled_default_false(): void { + // Hook Bus is opt-in; option not set → false. Existing sites store '1' explicitly. + $this->assertFalse( WPDO_Hook_Bus_Bridge::is_enabled() ); } public function test_is_enabled_when_option_set_to_string_one(): void { diff --git a/tests/unit/ModeManagerDefaultsTest.php b/tests/unit/ModeManagerDefaultsTest.php new file mode 100644 index 0000000..4a441f2 --- /dev/null +++ b/tests/unit/ModeManagerDefaultsTest.php @@ -0,0 +1,50 @@ +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'] ); + } +} -- 2.43.0 From b407f235acec0f525709bbcfdb8c616fd03e82b1 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 22:16:02 +0800 Subject: [PATCH 07/30] fix(2meet-data-optimizer): refuse uninstall while data lives only in flat tables - uninstall.php checks every site first and wp_die()s (keeping tables, options and plugin files) on aeav_only modes, misc rows, or zone modules past cutover; a failed bootstrap also refuses - Multisite now checks and cleans every site: network activation is always false at uninstall, so only the main site was being cleaned before - Drop the inline fallback DROP loop, unreachable now that a missing helper refuses instead Verify: UninstallGuardTest (8 tests incl. running uninstall.php single and multisite); unit 602 OK, integration 429 OK PR: PR-008 --- includes/class-tmdo-installer.php | 52 +++++ tests/integration/UninstallGuardTest.php | 120 ++++++++++++ uninstall.php | 233 ++++++----------------- 3 files changed, 234 insertions(+), 171 deletions(-) create mode 100644 tests/integration/UninstallGuardTest.php diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index b003c07..64c8e0f 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -297,6 +297,58 @@ class TMDO_Installer { 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) ───────────────── // These tables are installed only when v2.0.0 upgrade runs (PR-7). // install_v2_tables() is idempotent; safe to call multiple times. diff --git a/tests/integration/UninstallGuardTest.php b/tests/integration/UninstallGuardTest.php new file mode 100644 index 0000000..5027c62 --- /dev/null +++ b/tests/integration/UninstallGuardTest.php @@ -0,0 +1,120 @@ +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'] ); + } + } +} diff --git a/uninstall.php b/uninstall.php index 4fa1c0a..8f7e54f 100644 --- a/uninstall.php +++ b/uninstall.php @@ -5,14 +5,18 @@ * Runs only when the plugin is deleted via the WordPress admin. * 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. * The snapshot file under wp-content/uploads/wpdo-backups/ survives plugin * deletion (uploads dir is never auto-purged), so the user can re-install + * `wp wpdo snapshot restore --apply` to recover. * - * v2.14.0 multisite hardening: when network-uninstalled, iterate all sites - * via switch_to_blog so every wp_N_wpdo_* table is dropped (previously only - * the main site got cleaned, leaving orphans on every other site). + * Multisite: every site is checked and cleaned. The plugin may have been active + * per site, and it is always inactive by the time it can be deleted, so network + * activation cannot tell which sites hold wpdo_* tables. * * @package WP_Data_Optimizer */ @@ -44,11 +48,61 @@ foreach ( $bootstrap_files as $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 || ! method_exists( 'TMDO_Installer', 'uninstall_blockers' ) ) { + 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( + '

' . esc_html( '2meet Data Optimizer 已停止解除安裝:以下資料只存在 wpdo_* 表,刪除外掛會一併刪除這些表。' ) . '

' + . '
  • ' . implode( '
  • ', array_map( 'esc_html', $wpdo_blocked ) ) . '
' + . '

' . esc_html( '目前沒有把這些資料寫回 WordPress 原生表的自動工具。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '

' + ); +} + // ── 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 // wpdo_snapshots rows (which is per-site so per-site snapshots survive the // 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( 'pre_uninstall', array(), @@ -75,172 +129,9 @@ if ( $bootstrap_ok && class_exists( 'TMDO_Snapshot_Manager' ) ) { } } -// ── v2.14.0: shared cleanup helper ─────────────────────────────────────── -// Falls back to inline DROP loop if the helper class isn't loadable -// (e.g. partially-broken install where includes/class-tmdo-installer.php -// 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 ) { +// ── Drop wpdo_* tables and options on every site ───────────────────────── +$wpdo_for_each_site( + static function (): void { 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(); -} +); -- 2.43.0 From 9dc4516ca01bcc239dfd56d06bf2c185f23c0ab2 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 23:10:46 +0800 Subject: [PATCH 08/30] fix(2meet-data-optimizer): filter REST listings collection by post_status and post_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /wpdo/v1/listings rejects unregistered or non-viewable post types (400) and joins posts so Zone A only lists published posts of that type - WP_Query fallback now returns only show_in_rest hot columns, matching the Zone A path - Verify: core unit 605 / integration 432 OK (new RestApi tests); 1M bench: page 7ms, total COUNT 0.12s → 1.7s (tracked in PR-030) - PR: PR-015 --- includes/class-tmdo-rest-api.php | 35 ++++++++----- tests/bootstrap.php | 14 ++++- tests/integration/RestApiIntegrationTest.php | 54 +++++++++++++++++++- tests/integration/bootstrap.php | 3 ++ tests/unit/RestApiTest.php | 38 ++++++++++++++ 5 files changed, 129 insertions(+), 15 deletions(-) diff --git a/includes/class-tmdo-rest-api.php b/includes/class-tmdo-rest-api.php index e3616b0..439245a 100644 --- a/includes/class-tmdo-rest-api.php +++ b/includes/class-tmdo-rest-api.php @@ -530,6 +530,15 @@ class TMDO_REST_API { */ public function get_listings( WP_REST_Request $request ): WP_REST_Response { $post_type = $request->get_param( 'post_type' ) ?? 'hp_listing'; + 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. // 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 ); @@ -1365,6 +1374,10 @@ class TMDO_REST_API { /** * 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 int $per_page Number of items per page. * @param int $offset Database offset. @@ -1396,6 +1409,8 @@ class TMDO_REST_API { } $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. $allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) ); @@ -1404,16 +1419,12 @@ class TMDO_REST_API { // 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. - if ( $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}`" ); - } + $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` t {$join_sql} {$where_sql}", ...$params ) ); // Fetch rows. $rows = $wpdb->get_results( $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_A @@ -1476,8 +1487,8 @@ class TMDO_REST_API { 'id' => $post->ID, 'post_type' => $post->post_type, ); - $cols = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ); - foreach ( array_keys( $cols ) as $col ) { + // Same show_in_rest restriction as the Zone A path. + foreach ( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) as $col ) { $item[ $col ] = get_post_meta( $post->ID, $col, true ); } $items[] = $item; @@ -1507,7 +1518,7 @@ class TMDO_REST_API { $min = $request->get_param( $col . '_min' ); if ( null !== $min && is_numeric( $min ) ) { $filters[] = array( - 'sql' => "`{$col}` >= %f", + 'sql' => "t.`{$col}` >= %f", 'value' => (float) $min, ); } @@ -1515,7 +1526,7 @@ class TMDO_REST_API { $max = $request->get_param( $col . '_max' ); if ( null !== $max && is_numeric( $max ) ) { $filters[] = array( - 'sql' => "`{$col}` <= %f", + 'sql' => "t.`{$col}` <= %f", 'value' => (float) $max, ); } @@ -1524,12 +1535,12 @@ class TMDO_REST_API { if ( null !== $exact && '' !== $exact ) { if ( is_numeric( $exact ) ) { $filters[] = array( - 'sql' => "`{$col}` = %f", + 'sql' => "t.`{$col}` = %f", 'value' => (float) $exact, ); } else { $filters[] = array( - 'sql' => "`{$col}` = %s", + 'sql' => "t.`{$col}` = %s", 'value' => (string) $exact, ); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f10be06..8c1440e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -426,6 +426,18 @@ if ( ! function_exists( 'wp_cache_delete' ) ) { 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 ─────────────────────────────────────────────────────────── if ( ! class_exists( 'WP_Query' ) ) { @@ -434,7 +446,7 @@ if ( ! class_exists( 'WP_Query' ) ) { public int $found_posts = 0; public int $max_num_pages = 0; 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 set( string $key, $value ): void { $this->args[ $key ] = $value; } } diff --git a/tests/integration/RestApiIntegrationTest.php b/tests/integration/RestApiIntegrationTest.php index bc2343a..4580cd2 100644 --- a/tests/integration/RestApiIntegrationTest.php +++ b/tests/integration/RestApiIntegrationTest.php @@ -72,12 +72,29 @@ class RestApiIntegrationTest extends TestCase { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); - // Seed 5 rows. - for ( $i = 1; $i <= 5; $i++ ) { + // Posts table: listings are filtered by post_status against it. + $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; $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 `{$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. WPDO_Feature_Flags::set( 'hot_restapi', 'cutover' ); @@ -87,6 +104,7 @@ class RestApiIntegrationTest extends TestCase { global $wpdb; $wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" ); $wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" ); + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" ); WPDO_Feature_Flags::reset( 'hot_restapi' ); } @@ -97,6 +115,7 @@ class RestApiIntegrationTest extends TestCase { $GLOBALS['_wp_current_user_can'] = []; $GLOBALS['_wp_valid_nonces'] = []; $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. // Invalidate Feature Flags static request cache so each test reads fresh. @@ -232,6 +251,37 @@ class RestApiIntegrationTest extends TestCase { $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} ─────────────────────────────────────────────────── public function test_get_listing_404_for_unknown_post(): void { diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 4b3a0a3..23616ef 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -337,6 +337,9 @@ if ( ! function_exists( 'is_post_publicly_viewable' ) ) { 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' ) ) { 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 ]; } } diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php index a24085c..ede0a39 100644 --- a/tests/unit/RestApiTest.php +++ b/tests/unit/RestApiTest.php @@ -21,6 +21,8 @@ class RestApiTest extends TestCase { $GLOBALS['_wp_post_types'] = []; $GLOBALS['_wp_current_user_can'] = []; $GLOBALS['_wp_valid_nonces'] = []; + $GLOBALS['_wp_query_posts'] = []; + $GLOBALS['_wp_registered_post_types'] = [ 'hp_listing' => true ]; $_COOKIE = []; // Reset Feature Flags cache via reflection. @@ -153,6 +155,42 @@ class RestApiTest extends TestCase { $this->assertIsArray( $response->get_data() ); } + 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 ──────────────────────────────────────────────────── public function test_listings_fallback_sets_pagination_headers(): void { -- 2.43.0 From 07db7734cf68abdb43c057a0e046611cb784a4b4 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 23:19:22 +0800 Subject: [PATCH 09/30] refactor(2meet-data-optimizer): delete dead HivePress defaults and hardcoded partner list - Remove never-called register_hivepress_defaults() and core boot probes for AddOn classes that load at plugins_loaded:3/:6 and self-register (8 partner list entries, WC orders interceptor, HP transient filter) - Keep core-owned TMDO_Member_Fields / TMDO_Post_Fields registration unchanged - Verify: CoreBoundaryTest guard added; core unit 606 / integration 432, HP 141/49, WC 10/22 OK - PR: PR-016 --- includes/class-tmdo-core.php | 250 +------------------------------- tests/unit/CoreBoundaryTest.php | 34 +++++ 2 files changed, 38 insertions(+), 246 deletions(-) diff --git a/includes/class-tmdo-core.php b/includes/class-tmdo-core.php index 781c745..21a8846 100644 --- a/includes/class-tmdo-core.php +++ b/includes/class-tmdo-core.php @@ -31,31 +31,10 @@ class TMDO_Core { // Fire the schema registry hook so integrations can register their fields. $registry = TMDO_Schema_Registry::instance(); - // v2.0.x: Pre-register the 7 partner plugin integrations BEFORE firing the - // `wpdo_register_fields` and `wpdo_register_custom_tables` hooks. Each - // `register()` is a thin `add_action()` wrapper guarded by a partner-plugin - // `class_exists()` probe (TMDO_Infocards / Bookings / Quotation / Events / - // 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 ) { + // Core-owned field providers register before `wpdo_register_fields` fires. + // AddOns wire their own integrations from their bootstraps + // (plugins_loaded:3 or :6), so core does not probe for them here. + foreach ( array( 'TMDO_Member_Fields', 'TMDO_Post_Fields' ) as $cls ) { if ( class_exists( $cls ) ) { 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+, // 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. - // 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 // plugins_loaded:20 (e.g. 2meet-spoke-sso) can register entity-based user/term/comment @@ -116,12 +94,6 @@ class TMDO_Core { $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. if ( TMDO_Compatibility::can_register_zone_hooks() ) { $this->register_zone_hooks(); @@ -149,10 +121,6 @@ class TMDO_Core { 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. $this->schedule_cron(); @@ -182,14 +150,6 @@ class TMDO_Core { 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 // (silent-drop _wxr_import_* / _2meet_demo_* / orphan post-meta keys). if ( class_exists( 'TMDO_Term_Comment_Garbage_Filter' ) ) { @@ -282,208 +242,6 @@ class TMDO_Core { 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. */ diff --git a/tests/unit/CoreBoundaryTest.php b/tests/unit/CoreBoundaryTest.php index a1a3381..d29595f 100644 --- a/tests/unit/CoreBoundaryTest.php +++ b/tests/unit/CoreBoundaryTest.php @@ -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. */ -- 2.43.0 From e9d8696c93939045c03a9a8b46c1324ce6163f05 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 23:27:05 +0800 Subject: [PATCH 10/30] fix(2meet-data-optimizer): report entity group name collisions instead of silently dropping - register_group() keeps the first definition but records a field diff (added/removed/changed) when the same entity/group is re-registered differently; identical re-registration stays silent - Surface collisions via Site Health (wpdo_entity_group_collisions), the admin Doctor tab, and wp wpdo doctor; warning log throttled to once a day - Verify: EntityRegistryCollisionTest (7); core unit 613 / integration 432, HP 141/49, WC 10/22 OK - PR: PR-017 --- admin/class-tmdo-admin.php | 17 ++-- admin/class-tmdo-help-tabs.php | 3 +- cli/class-tmdo-cli.php | 19 ++++ .../diagnostic/class-tmdo-site-health.php | 75 +++++++++++--- .../engine/class-tmdo-entity-registry.php | 70 +++++++++++-- tests/unit/EntityRegistryCollisionTest.php | 97 +++++++++++++++++++ 6 files changed, 248 insertions(+), 33 deletions(-) create mode 100644 tests/unit/EntityRegistryCollisionTest.php diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php index e4fbc65..c3afe3a 100644 --- a/admin/class-tmdo-admin.php +++ b/admin/class-tmdo-admin.php @@ -2990,7 +2990,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' ?>

- +

@@ -3004,13 +3004,14 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' } $tests = array( - 'wpdo_schema_drift' => array( 'check_schema_drift', __( 'Schema 完整性', '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_autoload_bloat' => array( 'check_autoload_bloat', __( 'Autoload 大小', '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_missing_snapshot' => array( 'check_missing_snapshot', __( '缺少最近快照', '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_hook_conflicts' => array( 'check_hook_conflicts', __( 'Hook 衝突', '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_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_entity_group_collisions' => array( 'check_entity_group_collisions', __( '實體群組名稱衝突', '2meet-data-optimizer' ) ), ); ?> diff --git a/admin/class-tmdo-help-tabs.php b/admin/class-tmdo-help-tabs.php index c1c36ba..b4899a4 100644 --- a/admin/class-tmdo-help-tabs.php +++ b/admin/class-tmdo-help-tabs.php @@ -184,7 +184,7 @@ class TMDO_Help_Tabs { * @return string */ private static function content_doctor(): string { - return '

' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '

' + return '

' . esc_html__( '8 項自動健康檢查:', '2meet-data-optimizer' ) . '

' . '
    ' . '
  1. schema_drift — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '
  2. ' . '
  3. error_budget — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '
  4. ' @@ -193,6 +193,7 @@ class TMDO_Help_Tabs { . '
  5. postmeta_explosion — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '
  6. ' . '
  7. orphan_zone_rows — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '
  8. ' . '
  9. missing_snapshot — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '
  10. ' + . '
  11. entity_group_collisions — ' . esc_html__( '同名實體群組被不同外掛以不同欄位註冊(後者被忽略)', '2meet-data-optimizer' ) . '
  12. ' . '
' . '

' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '

'; } diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php index e227736..5c0023c 100644 --- a/cli/class-tmdo-cli.php +++ b/cli/class-tmdo-cli.php @@ -330,6 +330,25 @@ class TMDO_CLI { 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; + } + // Check recent errors. $errors = TMDO_Logger::get_recent( '', 5 ); if ( ! empty( $errors ) ) { diff --git a/includes/diagnostic/class-tmdo-site-health.php b/includes/diagnostic/class-tmdo-site-health.php index 77db2d9..bba38c2 100644 --- a/includes/diagnostic/class-tmdo-site-health.php +++ b/includes/diagnostic/class-tmdo-site-health.php @@ -2,7 +2,7 @@ /** * TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3). * - * Registers 7 tests under Tools → Site Health → Status: + * Registers 8 tests under Tools → Site Health → Status: * 1. wpdo_schema_drift (critical) * 2. wpdo_error_budget (recommended) * 3. wpdo_hook_conflicts (recommended) @@ -10,6 +10,7 @@ * 5. wpdo_postmeta_explosion (recommended) * 6. wpdo_orphan_zone_rows (recommended) * 7. wpdo_missing_snapshot (critical) + * 8. wpdo_entity_group_collisions (recommended) * * Each test result is cached for 5 minutes to keep Site Health responsive. * @@ -34,13 +35,14 @@ class TMDO_Site_Health { /** Test name → callable suffix mapping. */ private const TESTS = array( - 'wpdo_schema_drift' => 'check_schema_drift', - 'wpdo_error_budget' => 'check_error_budget', - 'wpdo_hook_conflicts' => 'check_hook_conflicts', - 'wpdo_autoload_bloat' => 'check_autoload_bloat', - 'wpdo_postmeta_explosion' => 'check_postmeta_explosion', - 'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows', - 'wpdo_missing_snapshot' => 'check_missing_snapshot', + 'wpdo_schema_drift' => 'check_schema_drift', + 'wpdo_error_budget' => 'check_error_budget', + 'wpdo_hook_conflicts' => 'check_hook_conflicts', + 'wpdo_autoload_bloat' => 'check_autoload_bloat', + 'wpdo_postmeta_explosion' => 'check_postmeta_explosion', + 'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows', + 'wpdo_missing_snapshot' => 'check_missing_snapshot', + 'wpdo_entity_group_collisions' => 'check_entity_group_collisions', ); /** @@ -53,7 +55,7 @@ class TMDO_Site_Health { } /** - * Register the 7 tests with WP Site Health. + * Register the 8 tests with WP Site Health. * * @param array $tests Existing tests. * @return array @@ -76,13 +78,14 @@ class TMDO_Site_Health { */ private static function label_for( string $key ): string { $map = array( - 'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ), - 'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ), - 'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ), - 'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ), - 'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ), - 'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ), - 'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ), + 'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ), + 'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ), + 'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ), + 'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ), + 'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ), + 'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ), + 'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ), + 'wpdo_entity_group_collisions' => __( 'WPDO entity group collisions', '2meet-data-optimizer' ), ); return $map[ $key ] ?? $key; } @@ -425,6 +428,46 @@ class TMDO_Site_Health { // ─── 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 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' + ) + ); + } + /** * Wrap a raw check result with required Site Health envelope fields. * diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php index c4e0a5a..c6e6ec1 100644 --- a/includes/engine/class-tmdo-entity-registry.php +++ b/includes/engine/class-tmdo-entity-registry.php @@ -31,6 +31,9 @@ final class TMDO_Entity_Registry { /** @var array 待建表的 Schema 清單 */ private static array $pending_schemas = array(); + /** @var array "entity/group" → 同名群組以不同定義重複註冊的紀錄 */ + private static array $collisions = array(); + /** 合法的欄位型別 */ public const VALID_TYPES = array( 'text', @@ -54,6 +57,7 @@ final class TMDO_Entity_Registry { self::$groups = array(); self::$field_index = array(); self::$pending_schemas = array(); + self::$collisions = array(); } // ───────────────────────────────────────────────────────── @@ -105,11 +109,6 @@ final class TMDO_Entity_Registry { return false; } - // 防重複登錄 - if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) { - return false; - } - // 驗證並正規化欄位定義 $normalized = array(); @@ -141,15 +140,25 @@ final class TMDO_Entity_Registry { $normalized_field['entity_type'] = $entity_type; $normalized[] = $normalized_field; - - // 建立索引以供快速查詢 - self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field; } if ( empty( $normalized ) ) { 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; + } + + // 建立索引以供快速查詢 + foreach ( $normalized as $normalized_field ) { + self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field; + } + self::$groups[ $entity_type ][ $group_name ] = $normalized; // 加入待建表佇列 @@ -162,6 +171,51 @@ final class TMDO_Entity_Registry { return true; } + /** + * 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。 + * + * 註冊每個請求都會跑,warning 以 transient 節流為每天一次,避免灌爆 wpdo_errors。 + */ + 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 + */ + public static function get_collisions(): array { + return array_values( self::$collisions ); + } + // ───────────────────────────────────────────────────────── // 查詢 API // ───────────────────────────────────────────────────────── diff --git a/tests/unit/EntityRegistryCollisionTest.php b/tests/unit/EntityRegistryCollisionTest.php new file mode 100644 index 0000000..1b21b49 --- /dev/null +++ b/tests/unit/EntityRegistryCollisionTest.php @@ -0,0 +1,97 @@ +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'] ); + } +} -- 2.43.0 From 1653011f4ff8b955630354e901f44046e321a63f Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 23:38:12 +0800 Subject: [PATCH 11/30] feat(2meet-data-optimizer): guard orphaned flat-table groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - On init:1, re-register aeav_only groups whose flat table exists and whose definition is in wpdo_registry_meta but no active plugin registers them, so their data stays readable (routing only, never DDL) - Keys owned by a live group, or with rows in native meta (newer data written while orphaned), are left alone; the native-meta check is cached for a day - Surface restored groups via Site Health (wpdo_orphaned_entity_groups), the admin Doctor tab, and wp wpdo doctor - Verify: EntityRegistryRestoreTest (9), OrphanGroupRestoreTest (7); core unit 622 / integration 439, HP 141/49, WC 10/22 OK; dry-run on dev20–22 restores 16/17/4 groups, keeps hp_taxonomy hp_sort_order/hp_icon on native - PR: PR-018 --- admin/class-tmdo-admin.php | 3 +- admin/class-tmdo-help-tabs.php | 3 +- cli/class-tmdo-cli.php | 15 ++ includes/class-tmdo-core.php | 6 + .../diagnostic/class-tmdo-site-health.php | 47 ++++- .../engine/class-tmdo-entity-registry.php | 114 +++++++++-- includes/engine/class-tmdo-schema-manager.php | 107 ++++++++++ tests/integration/OrphanGroupRestoreTest.php | 192 ++++++++++++++++++ tests/unit/EntityRegistryRestoreTest.php | 116 +++++++++++ 9 files changed, 583 insertions(+), 20 deletions(-) create mode 100644 tests/integration/OrphanGroupRestoreTest.php create mode 100644 tests/unit/EntityRegistryRestoreTest.php diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php index c3afe3a..ecc67bf 100644 --- a/admin/class-tmdo-admin.php +++ b/admin/class-tmdo-admin.php @@ -2990,7 +2990,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' ?>

- +

@@ -3012,6 +3012,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));' '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_entity_group_collisions' => array( 'check_entity_group_collisions', __( '實體群組名稱衝突', '2meet-data-optimizer' ) ), + 'wpdo_orphaned_entity_groups' => array( 'check_orphaned_entity_groups', __( '孤兒實體群組', '2meet-data-optimizer' ) ), ); ?>

diff --git a/admin/class-tmdo-help-tabs.php b/admin/class-tmdo-help-tabs.php index b4899a4..8aa3066 100644 --- a/admin/class-tmdo-help-tabs.php +++ b/admin/class-tmdo-help-tabs.php @@ -184,7 +184,7 @@ class TMDO_Help_Tabs { * @return string */ private static function content_doctor(): string { - return '

' . esc_html__( '8 項自動健康檢查:', '2meet-data-optimizer' ) . '

' + return '

' . esc_html__( '9 項自動健康檢查:', '2meet-data-optimizer' ) . '

' . '
    ' . '
  1. schema_drift — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '
  2. ' . '
  3. error_budget — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '
  4. ' @@ -194,6 +194,7 @@ class TMDO_Help_Tabs { . '
  5. orphan_zone_rows — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '
  6. ' . '
  7. missing_snapshot — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '
  8. ' . '
  9. entity_group_collisions — ' . esc_html__( '同名實體群組被不同外掛以不同欄位註冊(後者被忽略)', '2meet-data-optimizer' ) . '
  10. ' + . '
  11. orphaned_entity_groups — ' . esc_html__( '扁平表還在但已無外掛註冊的群組(已依保存的定義還原以維持可讀)', '2meet-data-optimizer' ) . '
  12. ' . '
' . '

' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '

'; } diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php index 5c0023c..da7b033 100644 --- a/cli/class-tmdo-cli.php +++ b/cli/class-tmdo-cli.php @@ -349,6 +349,21 @@ class TMDO_CLI { $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. $errors = TMDO_Logger::get_recent( '', 5 ); if ( ! empty( $errors ) ) { diff --git a/includes/class-tmdo-core.php b/includes/class-tmdo-core.php index 21a8846..4a4a459 100644 --- a/includes/class-tmdo-core.php +++ b/includes/class-tmdo-core.php @@ -71,6 +71,12 @@ class TMDO_Core { if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) { 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' ) ) { $pending = TMDO_Entity_Registry::get_pending_schemas(); if ( ! empty( $pending ) ) { diff --git a/includes/diagnostic/class-tmdo-site-health.php b/includes/diagnostic/class-tmdo-site-health.php index bba38c2..e2a8722 100644 --- a/includes/diagnostic/class-tmdo-site-health.php +++ b/includes/diagnostic/class-tmdo-site-health.php @@ -2,7 +2,7 @@ /** * TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3). * - * Registers 8 tests under Tools → Site Health → Status: + * Registers 9 tests under Tools → Site Health → Status: * 1. wpdo_schema_drift (critical) * 2. wpdo_error_budget (recommended) * 3. wpdo_hook_conflicts (recommended) @@ -11,6 +11,7 @@ * 6. wpdo_orphan_zone_rows (recommended) * 7. wpdo_missing_snapshot (critical) * 8. wpdo_entity_group_collisions (recommended) + * 9. wpdo_orphaned_entity_groups (recommended) * * Each test result is cached for 5 minutes to keep Site Health responsive. * @@ -43,6 +44,7 @@ class TMDO_Site_Health { 'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows', 'wpdo_missing_snapshot' => 'check_missing_snapshot', 'wpdo_entity_group_collisions' => 'check_entity_group_collisions', + 'wpdo_orphaned_entity_groups' => 'check_orphaned_entity_groups', ); /** @@ -55,7 +57,7 @@ class TMDO_Site_Health { } /** - * Register the 8 tests with WP Site Health. + * Register the 9 tests with WP Site Health. * * @param array $tests Existing tests. * @return array @@ -86,6 +88,7 @@ class TMDO_Site_Health { 'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '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' ), ); return $map[ $key ] ?? $key; } @@ -468,6 +471,46 @@ class TMDO_Site_Health { ); } + // ─── 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 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' + ) + ); + } + /** * Wrap a raw check result with required Site Health envelope fields. * diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php index c6e6ec1..d355b46 100644 --- a/includes/engine/class-tmdo-entity-registry.php +++ b/includes/engine/class-tmdo-entity-registry.php @@ -34,6 +34,9 @@ final class TMDO_Entity_Registry { /** @var array "entity/group" → 同名群組以不同定義重複註冊的紀錄 */ private static array $collisions = array(); + /** @var array "entity/group" → 從 wpdo_registry_meta 還原的孤兒群組 */ + private static array $restored = array(); + /** 合法的欄位型別 */ public const VALID_TYPES = array( 'text', @@ -58,6 +61,7 @@ final class TMDO_Entity_Registry { self::$field_index = array(); self::$pending_schemas = array(); self::$collisions = array(); + self::$restored = array(); } // ───────────────────────────────────────────────────────── @@ -109,7 +113,41 @@ final class TMDO_Entity_Registry { return false; } - // 驗證並正規化欄位定義 + $normalized = self::normalize_fields( $entity_type, $group_name, $fields ); + + if ( empty( $normalized ) ) { + 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; + } + + // 建立索引以供快速查詢 + foreach ( $normalized as $normalized_field ) { + self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field; + } + + self::$groups[ $entity_type ][ $group_name ] = $normalized; + + // 加入待建表佇列 + self::$pending_schemas[] = array( + 'type' => $entity_type, + 'group' => $group_name, + 'fields' => $normalized, + ); + + return true; + } + + /** + * 驗證並正規化欄位定義(略過缺 key/type 或型別不合法者)。 + */ + private static function normalize_fields( string $entity_type, string $group_name, array $fields ): array { $normalized = array(); foreach ( $fields as $field ) { @@ -142,35 +180,79 @@ final class TMDO_Entity_Registry { $normalized[] = $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 + */ + 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; } - // 防重複登錄:相同定義(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 ); + $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; + if ( empty( $kept ) ) { + // 整組都留在原生 meta 時仍要回報:扁平表的舊資料需要人工處理。 + if ( $native ) { + self::$restored[ $id ] = array( + 'entity_type' => $entity_type, + 'group' => $group_name, + 'fields' => 0, + 'skipped' => $skipped, + 'native' => $native, + ); } return false; } - // 建立索引以供快速查詢 - foreach ( $normalized as $normalized_field ) { - self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field; + foreach ( $kept as $field ) { + self::$field_index[ $entity_type ][ $field['key'] ] = $field; } + self::$groups[ $entity_type ][ $group_name ] = $kept; - self::$groups[ $entity_type ][ $group_name ] = $normalized; - - // 加入待建表佇列 - self::$pending_schemas[] = array( - 'type' => $entity_type, - 'group' => $group_name, - 'fields' => $normalized, + self::$restored[ $id ] = array( + 'entity_type' => $entity_type, + 'group' => $group_name, + 'fields' => count( $kept ), + 'skipped' => $skipped, + 'native' => $native, ); return true; } + /** + * 本請求從 wpdo_registry_meta 還原的孤兒群組。 + * + * @return array + */ + public static function get_restored_groups(): array { + return array_values( self::$restored ); + } + /** * 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。 * diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php index 9188165..bd2fc78 100644 --- a/includes/engine/class-tmdo-schema-manager.php +++ b/includes/engine/class-tmdo-schema-manager.php @@ -268,6 +268,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 */ diff --git a/tests/integration/OrphanGroupRestoreTest.php b/tests/integration/OrphanGroupRestoreTest.php new file mode 100644 index 0000000..b2416d9 --- /dev/null +++ b/tests/integration/OrphanGroupRestoreTest.php @@ -0,0 +1,192 @@ +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', + ) + ); + } +} diff --git a/tests/unit/EntityRegistryRestoreTest.php b/tests/unit/EntityRegistryRestoreTest.php new file mode 100644 index 0000000..9f7a77c --- /dev/null +++ b/tests/unit/EntityRegistryRestoreTest.php @@ -0,0 +1,116 @@ +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'] ); + } +} -- 2.43.0 From 3f4fe52e1b5e900629b249093fd1b44486b78b29 Mon Sep 17 00:00:00 2001 From: wpdev Date: Fri, 25 Sep 2026 23:55:49 +0800 Subject: [PATCH 12/30] fix(2meet-data-optimizer): route post entity keys by post_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity groups can declare post_types; get_field() resolves a post's key against its own type's groups first, then unscoped ones (legacy last-wins lookup kept for callers without a post type) - Hook Bus and the post query router pass the post type, so a listing's hp_rating / hp_status / hp_verified map to hp_listing_core instead of the vendor/request tables; core HP groups declare their post types (schema hashes unchanged) - Behind tmdo_post_scoped_routing: on for sites without post flat rows, off where rows may sit in the wrong table until PR-034 reconciles them (dry-run: dev20–22 off, dev23/24/30/31/40 on) - Verify: EntityRegistryScopeTest (8), PostScopedRoutingTest (4); core unit 630 / integration 443, HP 144/49, WC 10/22 OK - PR: PR-033 --- includes/class-tmdo-installer.php | 42 ++++++ .../engine/class-tmdo-entity-registry.php | 55 +++++++- includes/engine/class-tmdo-hook-bus.php | 23 +++- .../integrations/class-tmdo-post-fields.php | 9 +- .../query/class-tmdo-post-query-router.php | 2 +- tests/integration/PostScopedRoutingTest.php | 121 ++++++++++++++++++ tests/unit/EntityRegistryScopeTest.php | 102 +++++++++++++++ 7 files changed, 340 insertions(+), 14 deletions(-) create mode 100644 tests/integration/PostScopedRoutingTest.php create mode 100644 tests/unit/EntityRegistryScopeTest.php diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index 64c8e0f..7e97f73 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -268,6 +268,48 @@ class TMDO_Installer { 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'; } /** diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php index d355b46..8bcf47d 100644 --- a/includes/engine/class-tmdo-entity-registry.php +++ b/includes/engine/class-tmdo-entity-registry.php @@ -31,6 +31,12 @@ final class TMDO_Entity_Registry { /** @var array 待建表的 Schema 清單 */ private static array $pending_schemas = array(); + /** @var array> 實體類型 → meta_key → 欄位定義(僅未限定 post_type 的群組)*/ + private static array $unscoped_index = array(); + + /** @var array>> 實體類型 → post_type → meta_key → 欄位定義 */ + private static array $scoped_index = array(); + /** @var array "entity/group" → 同名群組以不同定義重複註冊的紀錄 */ private static array $collisions = array(); @@ -55,10 +61,15 @@ final class TMDO_Entity_Registry { /** 合法的實體類型 */ 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 { self::$adapters = array(); self::$groups = array(); self::$field_index = array(); + self::$unscoped_index = array(); + self::$scoped_index = array(); self::$pending_schemas = array(); self::$collisions = array(); self::$restored = array(); @@ -100,10 +111,14 @@ final class TMDO_Entity_Registry { * - unique (bool) 唯一索引 * - options (array) enum 選項 * - label (string) 顯示名稱 + * @param array $args 選填:post_types (string[]) — 群組只套用於這些 post_type。 + * 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬; + * 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。 + * 不列入欄位定義,所以不影響 schema hash 與衝突比對。 * * @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 ) ) { return false; @@ -128,8 +143,9 @@ final class TMDO_Entity_Registry { } // 建立索引以供快速查詢 + $post_types = array_values( array_filter( array_map( 'strval', (array) ( $args['post_types'] ?? array() ) ) ) ); foreach ( $normalized as $normalized_field ) { - self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field; + self::index_field( $entity_type, $normalized_field, $post_types ); } self::$groups[ $entity_type ][ $group_name ] = $normalized; @@ -229,7 +245,7 @@ final class TMDO_Entity_Registry { } foreach ( $kept as $field ) { - self::$field_index[ $entity_type ][ $field['key'] ] = $field; + self::index_field( $entity_type, $field, array() ); } self::$groups[ $entity_type ][ $group_name ] = $kept; @@ -244,6 +260,24 @@ final class TMDO_Entity_Registry { return true; } + /** + * 把欄位加入索引。field_index 保留舊語意(整個實體共用、後註冊者覆蓋), + * 供不帶 post_type 的查詢使用;另外依是否限定 post_type 分別建索引。 + * + * @param string[] $post_types 群組限定的 post_type;空陣列表示不限定。 + */ + 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 還原的孤兒群組。 * @@ -305,10 +339,21 @@ final class TMDO_Entity_Registry { /** * 依 meta_key 查詢欄位定義 * + * 傳入 $post_type 且 SCOPED_ROUTING_OPTION 開啟時:先找限定該 post_type 的群組, + * 再找不限定的群組,都沒有則為 null。否則沿用舊語意(整個實體共用索引, + * 共用 key 由最後註冊的群組擁有)。舊資料可能已依舊語意散落在別的群組表, + * 所以既有站要等資料對齊後才開啟 option。 + * + * @param string $post_type 選填:post 的 post_type。 * @return array|null 欄位定義,或 null(非 UAE 管理欄位) */ - public static function get_field( string $entity_type, string $meta_key ): ?array { - return self::$field_index[ $entity_type ][ $meta_key ] ?? null; + public static function get_field( string $entity_type, string $meta_key, string $post_type = '' ): ?array { + 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; } /** diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index 6b25685..1f2f9d0 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -80,7 +80,7 @@ final class TMDO_Hook_Bus { 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 ) { return $check; // 非管理欄位,放行 } @@ -133,7 +133,7 @@ final class TMDO_Hook_Bus { 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 ) { return $check; } @@ -374,7 +374,7 @@ final class TMDO_Hook_Bus { 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 ) { return $check; } @@ -471,7 +471,7 @@ final class TMDO_Hook_Bus { 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 ) { return $check; } @@ -622,6 +622,19 @@ final class TMDO_Hook_Bus { return null; } + /** + * 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。 + * + * 先做不帶 post_type 的查詢:大多數 key 不受管理,可省下 get_post_type()。 + */ + 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 系統) * 供 wpdo_get_meta() 便利函式使用,性能更好 @@ -646,7 +659,7 @@ final class TMDO_Hook_Bus { return $result; } - $field_def = TMDO_Entity_Registry::get_field( $type, $key ); + $field_def = self::field_for( $type, $entity_id, $key ); if ( ! $field_def ) { return null; } diff --git a/includes/integrations/class-tmdo-post-fields.php b/includes/integrations/class-tmdo-post-fields.php index 6f2685d..c3e5574 100644 --- a/includes/integrations/class-tmdo-post-fields.php +++ b/includes/integrations/class-tmdo-post-fields.php @@ -340,7 +340,8 @@ final class TMDO_Post_Fields { 'type' => 'decimal', 'label' => 'Hourly rate', ), - ) + ), + array( 'post_types' => array( 'hp_listing' ) ) ); } @@ -384,7 +385,8 @@ final class TMDO_Post_Fields { 'type' => 'integer', 'label' => 'View counter', ), - ) + ), + array( 'post_types' => array( 'hp_request' ) ) ); } @@ -427,7 +429,8 @@ final class TMDO_Post_Fields { 'searchable' => true, 'label' => 'Average rating', ), - ) + ), + array( 'post_types' => array( 'hp_vendor' ) ) ); } diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index 6429769..ed4c5c3 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -110,7 +110,7 @@ class TMDO_Post_Query_Router { $matched = false; 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 ) { continue; } diff --git a/tests/integration/PostScopedRoutingTest.php b/tests/integration/PostScopedRoutingTest.php new file mode 100644 index 0000000..f2ec7f7 --- /dev/null +++ b/tests/integration/PostScopedRoutingTest.php @@ -0,0 +1,121 @@ +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 ) ); + } +} diff --git a/tests/unit/EntityRegistryScopeTest.php b/tests/unit/EntityRegistryScopeTest.php new file mode 100644 index 0000000..dfbb4fa --- /dev/null +++ b/tests/unit/EntityRegistryScopeTest.php @@ -0,0 +1,102 @@ +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 ) ); + } + + public function test_core_hivepress_groups_are_scoped_without_changing_their_hash(): void { + TMDO_Entity_Registry::init(); + TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); + TMDO_Post_Fields::register_entity_fields(); + + $persisted = array( + 'hp_listing_core' => 'fb5ab760', + 'hp_request_core' => '27d6f4f5', + 'hp_vendor_core' => '5366e5c8', + ); + foreach ( $persisted as $group => $hash ) { + $fields = TMDO_Entity_Registry::get_group_fields( 'post', $group ); + $this->assertSame( $hash, substr( TMDO_Schema_Manager::calculate_schema_hash( $fields ), 0, 8 ), $group ); + } + + $this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_rating', 'hp_listing' )['group'] ); + $this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_status', 'hp_listing' )['group'] ); + $this->assertSame( 'hp_request_core', TMDO_Entity_Registry::get_field( 'post', 'hp_status', 'hp_request' )['group'] ); + $this->assertSame( 'hp_vendor_core', TMDO_Entity_Registry::get_field( 'post', 'hp_user', 'hp_vendor' )['group'] ); + $this->assertSame( 'hp_request_core', TMDO_Entity_Registry::get_field( 'post', 'hp_user', 'hp_request' )['group'] ); + } +} -- 2.43.0 From e0683a19b1dbff43b0c77795ae4b87479a7972cc Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 26 Sep 2026 00:04:38 +0800 Subject: [PATCH 13/30] feat(2meet-data-optimizer): reconcile post rows written to the wrong group table - TMDO_Post_Group_Reconciler copies non-NULL values that legacy routing stored in another group's table into the post type's own group (NULL/missing legacy values keep the scoped value), resumable via a cursor, with a catch-up pass for rows updated during the run, then switches on tmdo_post_scoped_routing - CLI: wp tmdo post-reconcile-groups [--dry-run] [--batch=]; registry exposes get_group_post_types() - Verify: PostGroupReconcilerPlanTest (4), PostGroupReconcilerTest (6); core unit 635 / integration 449, HP 145/49, WC 10/22 OK; read-only dry-run: dev20 320 values / 0 overwrites, dev21/22 none - PR: PR-034 --- 2meet-data-optimizer.php | 1 + cli/class-tmdo-cli-post.php | 46 ++++ .../engine/class-tmdo-entity-registry.php | 16 ++ .../class-tmdo-post-group-reconciler.php | 221 ++++++++++++++++++ tests/bootstrap.php | 1 + tests/integration/PostGroupReconcilerTest.php | 141 +++++++++++ tests/integration/bootstrap.php | 1 + tests/unit/PostGroupReconcilerPlanTest.php | 79 +++++++ 8 files changed, 506 insertions(+) create mode 100644 includes/migration/class-tmdo-post-group-reconciler.php create mode 100644 tests/integration/PostGroupReconcilerTest.php create mode 100644 tests/unit/PostGroupReconcilerPlanTest.php diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index 653c501..3fa1bc4 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -258,6 +258,7 @@ 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/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-group-reconciler.php'; 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( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) ); diff --git a/cli/class-tmdo-cli-post.php b/cli/class-tmdo-cli-post.php index 717b525..1816e36 100644 --- a/cli/class-tmdo-cli-post.php +++ b/cli/class-tmdo-cli-post.php @@ -259,6 +259,50 @@ 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=] + * : Rows per batch. Default 500. + * + * ## EXAMPLES + * + * wp tmdo post-reconcile-groups --dry-run + * wp tmdo post-reconcile-groups + * + * @param array $args Positional arguments (unused). + * @param array $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-cleanup (v2.9.3) ───────────────────────────────────────────────── /** @@ -853,3 +897,5 @@ 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( '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( 'wpdo post-reconcile-groups', array( 'TMDO_CLI_Post', 'post_reconcile_groups' ) ); +WP_CLI::add_command( 'tmdo post-reconcile-groups', array( 'TMDO_CLI_Post', 'post_reconcile_groups' ) ); diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php index 8bcf47d..3e0a86f 100644 --- a/includes/engine/class-tmdo-entity-registry.php +++ b/includes/engine/class-tmdo-entity-registry.php @@ -37,6 +37,9 @@ final class TMDO_Entity_Registry { /** @var array>> 實體類型 → post_type → meta_key → 欄位定義 */ private static array $scoped_index = array(); + /** @var array> 實體類型 → 群組名 → 限定的 post_type */ + private static array $scopes = array(); + /** @var array "entity/group" → 同名群組以不同定義重複註冊的紀錄 */ private static array $collisions = array(); @@ -70,6 +73,7 @@ final class TMDO_Entity_Registry { self::$field_index = array(); self::$unscoped_index = array(); self::$scoped_index = array(); + self::$scopes = array(); self::$pending_schemas = array(); self::$collisions = array(); self::$restored = array(); @@ -144,6 +148,9 @@ final class TMDO_Entity_Registry { // 建立索引以供快速查詢 $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; + } foreach ( $normalized as $normalized_field ) { self::index_field( $entity_type, $normalized_field, $post_types ); } @@ -365,6 +372,15 @@ final class TMDO_Entity_Registry { return array_keys( self::$groups[ $entity_type ] ?? 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(); + } + /** * 取得特定群組的完整欄位定義 */ diff --git a/includes/migration/class-tmdo-post-group-reconciler.php b/includes/migration/class-tmdo-post-group-reconciler.php new file mode 100644 index 0000000..4f4655b --- /dev/null +++ b/includes/migration/class-tmdo-post-group-reconciler.php @@ -0,0 +1,221 @@ + + */ + 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, 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 $move One entry of plan(). + * @param string $id_col Entity id column of the flat tables. + * @return array{0:string, 1:array} + */ + 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 $move One entry of plan(). + * @param string $id_col Entity id column of the flat tables. + * @param array $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 ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8c1440e..275ab9b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -642,6 +642,7 @@ 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/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-group-reconciler.php'; // Options Manager. require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php'; diff --git a/tests/integration/PostGroupReconcilerTest.php b/tests/integration/PostGroupReconcilerTest.php new file mode 100644 index 0000000..6c768e9 --- /dev/null +++ b/tests/integration/PostGroupReconcilerTest.php @@ -0,0 +1,141 @@ +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 ) ); + } +} diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 23616ef..0302d7d 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -602,6 +602,7 @@ 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/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-group-reconciler.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-termmeta-cleaner.php'; diff --git a/tests/unit/PostGroupReconcilerPlanTest.php b/tests/unit/PostGroupReconcilerPlanTest.php new file mode 100644 index 0000000..4b8f3e7 --- /dev/null +++ b/tests/unit/PostGroupReconcilerPlanTest.php @@ -0,0 +1,79 @@ +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' ) ); + } + + public function test_core_hivepress_groups_plan_the_eight_misrouted_keys(): void { + TMDO_Post_Fields::register_entity_fields(); + + $moves = array(); + foreach ( TMDO_Post_Group_Reconciler::plan() as $m ) { + $moves[] = "{$m['source']}>{$m['target']}:{$m['key']}"; + } + sort( $moves ); + + $this->assertSame( + array( + 'hp_request_core>hp_listing_core:hp_expired_time', + 'hp_request_core>hp_listing_core:hp_status', + 'hp_request_core>hp_listing_core:hp_view_count', + 'hp_vendor_core>hp_listing_core:hp_hourly_rate', + 'hp_vendor_core>hp_listing_core:hp_rating', + 'hp_vendor_core>hp_listing_core:hp_rating_count', + 'hp_vendor_core>hp_listing_core:hp_verified', + 'hp_vendor_core>hp_request_core:hp_user', + ), + $moves + ); + } +} -- 2.43.0 From 1eedaa9b3cbfcc5dbacb0b9a0f1767e973d02f4c Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 26 Sep 2026 00:07:31 +0800 Subject: [PATCH 14/30] refactor(2meet-data-optimizer): stop registering HivePress entity groups in core - Drop hp_listing_core / hp_request_core / hp_vendor_core from TMDO_Post_Fields and hp_user from TMDO_Member_Fields; the HivePress AddOn registers identical definitions (PR-019/020a/033b) - Tests: core asserts it registers none of them; HP-specific core tests removed (covered by the AddOn); SyncBridgeEntityGuardTest registers its own hp_price group - Verify: core unit 633 / integration 449, HP 145/49, WC 10/22 OK - Deploy: activate the HivePress AddOn on dev20/21/22 first - PR: PR-020b --- .../integrations/class-tmdo-member-fields.php | 30 +-- .../integrations/class-tmdo-post-fields.php | 185 +----------------- .../integration/SyncBridgeEntityGuardTest.php | 5 +- tests/unit/EntityRegistryScopeTest.php | 22 --- .../MemberFieldsRegistrationTest.php | 6 + .../PostFields/PostFieldsRegistrationTest.php | 24 +-- tests/unit/PostGroupReconcilerPlanTest.php | 24 --- 7 files changed, 22 insertions(+), 274 deletions(-) diff --git a/includes/integrations/class-tmdo-member-fields.php b/includes/integrations/class-tmdo-member-fields.php index 7947554..776d9cd 100644 --- a/includes/integrations/class-tmdo-member-fields.php +++ b/includes/integrations/class-tmdo-member-fields.php @@ -43,6 +43,7 @@ final class TMDO_Member_Fields { * 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 — * 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 * EVERY new user via wp_insert_user (rich_editing, syntax_highlighting, @@ -64,7 +65,6 @@ final class TMDO_Member_Fields { self::register_core_profile_group(); self::register_social_group(); self::register_commerce_group(); - self::register_hp_user_group(); self::register_admin_prefs_group(); } @@ -414,34 +414,6 @@ final class TMDO_Member_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 * new user regardless of role. Without registering these, a fresh user diff --git a/includes/integrations/class-tmdo-post-fields.php b/includes/integrations/class-tmdo-post-fields.php index c3e5574..0f2d202 100644 --- a/includes/integrations/class-tmdo-post-fields.php +++ b/includes/integrations/class-tmdo-post-fields.php @@ -2,7 +2,7 @@ /** * 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. * 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) * attachment → wp_wpdo_post_attachment (attachment) * 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) * * Pattern mirrors TMDO_Member_Fields::register(). Called from * 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). * * Keys not registered here (HivePress dynamic attrs, WPCS legacy keys, * etc.) pass through to wp_postmeta unchanged — same fall-back behavior * 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() * with entity_type='user'. User entity registration is owned exclusively * by TMDO_Member_Fields. @@ -39,7 +39,7 @@ if ( ! defined( 'ABSPATH' ) ) { } /** - * Registers seven post entity groups. + * Registers four post entity groups. */ final class TMDO_Post_Fields { @@ -65,9 +65,6 @@ final class TMDO_Post_Fields { self::register_wp_core_group(); self::register_attachment_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(); } @@ -264,176 +261,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', - ), - ), - array( 'post_types' => array( 'hp_listing' ) ) - ); - } - - /** - * HivePress request core meta keys (post_type=hp_request). - * - * @return void - */ - private static function register_hp_request_core_group(): void { - TMDO_Entity_Registry::register_group( - 'post', - 'hp_request_core', - array( - array( - 'key' => 'hp_status', - 'type' => 'enum', - 'searchable' => true, - 'options' => array( 'publish', 'draft', 'pending', 'expired' ), - 'label' => 'Request status', - ), - array( - 'key' => 'hp_user', - 'type' => 'integer', - 'searchable' => true, - 'label' => 'Request author user ID', - ), - array( - 'key' => 'hp_expired_time', - 'type' => 'integer', - 'searchable' => true, - 'label' => 'Expiry unix ts', - ), - array( - 'key' => 'hp_budget', - 'type' => 'decimal', - 'searchable' => true, - 'label' => 'Budget amount', - ), - array( - 'key' => 'hp_view_count', - 'type' => 'integer', - 'label' => 'View counter', - ), - ), - array( 'post_types' => array( 'hp_request' ) ) - ); - } - - /** - * HivePress vendor core meta keys (post_type=hp_vendor). - * - * @return void - */ - private static function register_hp_vendor_core_group(): void { - TMDO_Entity_Registry::register_group( - 'post', - 'hp_vendor_core', - array( - array( - 'key' => 'hp_user', - 'type' => 'integer', - 'searchable' => true, - 'label' => 'Vendor user ID (linked WP user)', - ), - array( - 'key' => 'hp_verified', - 'type' => 'integer', - 'searchable' => true, - 'label' => 'Verified flag (0/1)', - ), - array( - 'key' => 'hp_hourly_rate', - 'type' => 'decimal', - 'searchable' => true, - 'label' => 'Hourly rate', - ), - array( - 'key' => 'hp_rating_count', - 'type' => 'integer', - 'label' => 'Rating count', - ), - array( - 'key' => 'hp_rating', - 'type' => 'decimal', - 'searchable' => true, - 'label' => 'Average rating', - ), - ), - array( 'post_types' => array( 'hp_vendor' ) ) - ); - } - /** * Nav menu item meta keys (post_type=nav_menu_item). * diff --git a/tests/integration/SyncBridgeEntityGuardTest.php b/tests/integration/SyncBridgeEntityGuardTest.php index 573fea4..3f943ff 100644 --- a/tests/integration/SyncBridgeEntityGuardTest.php +++ b/tests/integration/SyncBridgeEntityGuardTest.php @@ -103,10 +103,11 @@ class SyncBridgeEntityGuardTest extends TestCase { '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::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 { diff --git a/tests/unit/EntityRegistryScopeTest.php b/tests/unit/EntityRegistryScopeTest.php index dfbb4fa..1aeb1e4 100644 --- a/tests/unit/EntityRegistryScopeTest.php +++ b/tests/unit/EntityRegistryScopeTest.php @@ -77,26 +77,4 @@ class EntityRegistryScopeTest extends TestCase { $this->assertSame( '1', TMDO_Installer::post_scoped_routing_default( 'aeav_only', false ) ); $this->assertSame( '1', TMDO_Installer::post_scoped_routing_default( 'disabled', true ) ); } - - public function test_core_hivepress_groups_are_scoped_without_changing_their_hash(): void { - TMDO_Entity_Registry::init(); - TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); - TMDO_Post_Fields::register_entity_fields(); - - $persisted = array( - 'hp_listing_core' => 'fb5ab760', - 'hp_request_core' => '27d6f4f5', - 'hp_vendor_core' => '5366e5c8', - ); - foreach ( $persisted as $group => $hash ) { - $fields = TMDO_Entity_Registry::get_group_fields( 'post', $group ); - $this->assertSame( $hash, substr( TMDO_Schema_Manager::calculate_schema_hash( $fields ), 0, 8 ), $group ); - } - - $this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_rating', 'hp_listing' )['group'] ); - $this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_status', 'hp_listing' )['group'] ); - $this->assertSame( 'hp_request_core', TMDO_Entity_Registry::get_field( 'post', 'hp_status', 'hp_request' )['group'] ); - $this->assertSame( 'hp_vendor_core', TMDO_Entity_Registry::get_field( 'post', 'hp_user', 'hp_vendor' )['group'] ); - $this->assertSame( 'hp_request_core', TMDO_Entity_Registry::get_field( 'post', 'hp_user', 'hp_request' )['group'] ); - } } diff --git a/tests/unit/MemberFields/MemberFieldsRegistrationTest.php b/tests/unit/MemberFields/MemberFieldsRegistrationTest.php index d80d1ae..f3dcaea 100644 --- a/tests/unit/MemberFields/MemberFieldsRegistrationTest.php +++ b/tests/unit/MemberFields/MemberFieldsRegistrationTest.php @@ -45,6 +45,12 @@ class MemberFieldsRegistrationTest extends TestCase { } } + 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 ───────────────────────────────────────────────────── public function test_membership_group_has_six_fields(): void { diff --git a/tests/unit/PostFields/PostFieldsRegistrationTest.php b/tests/unit/PostFields/PostFieldsRegistrationTest.php index 3d1242e..42dc942 100644 --- a/tests/unit/PostFields/PostFieldsRegistrationTest.php +++ b/tests/unit/PostFields/PostFieldsRegistrationTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; /** * 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. * * Mirrors MemberFieldsRegistrationTest's structure. @@ -33,16 +33,13 @@ class PostFieldsRegistrationTest extends TestCase { $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(); $expected = array( 'wp_core', 'attachment', 'wc_product', - 'hp_listing_core', - 'hp_request_core', - 'hp_vendor_core', 'nav_menu_item', ); @@ -129,24 +126,15 @@ class PostFieldsRegistrationTest extends TestCase { $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(); - $keys = $this->get_field_keys( 'hp_listing_core' ); - foreach ( array( 'hp_price', 'hp_status', 'hp_featured', 'hp_verified', 'hp_vendor' ) as $key ) { - $this->assertContains( $key, $keys, "hp_listing_core missing critical key: {$key}" ); + foreach ( array( 'hp_listing_core', 'hp_request_core', 'hp_vendor_core' ) as $group ) { + $this->assertSame( array(), WPDO_Entity_Registry::get_group_fields( 'post', $group ), "{$group} is owned by the HivePress AddOn" ); } } - public function test_hp_listing_price_is_searchable_decimal(): void { - WPDO_Post_Fields::register_entity_fields(); - $field = $this->find_field( 'hp_listing_core', 'hp_price' ); - $this->assertNotNull( $field ); - $this->assertSame( 'decimal', $field['type'] ); - $this->assertTrue( (bool) ( $field['searchable'] ?? false ) ); - } - // ── nav_menu_item group ────────────────────────────────────────────────── public function test_nav_menu_item_has_8_keys(): void { diff --git a/tests/unit/PostGroupReconcilerPlanTest.php b/tests/unit/PostGroupReconcilerPlanTest.php index 4b8f3e7..72354f8 100644 --- a/tests/unit/PostGroupReconcilerPlanTest.php +++ b/tests/unit/PostGroupReconcilerPlanTest.php @@ -52,28 +52,4 @@ class PostGroupReconcilerPlanTest extends TestCase { TMDO_Entity_Registry::init(); $this->assertSame( array(), TMDO_Entity_Registry::get_group_post_types( 'post', 'listing' ) ); } - - public function test_core_hivepress_groups_plan_the_eight_misrouted_keys(): void { - TMDO_Post_Fields::register_entity_fields(); - - $moves = array(); - foreach ( TMDO_Post_Group_Reconciler::plan() as $m ) { - $moves[] = "{$m['source']}>{$m['target']}:{$m['key']}"; - } - sort( $moves ); - - $this->assertSame( - array( - 'hp_request_core>hp_listing_core:hp_expired_time', - 'hp_request_core>hp_listing_core:hp_status', - 'hp_request_core>hp_listing_core:hp_view_count', - 'hp_vendor_core>hp_listing_core:hp_hourly_rate', - 'hp_vendor_core>hp_listing_core:hp_rating', - 'hp_vendor_core>hp_listing_core:hp_rating_count', - 'hp_vendor_core>hp_listing_core:hp_verified', - 'hp_vendor_core>hp_request_core:hp_user', - ), - $moves - ); - } } -- 2.43.0 From 8a1c9c03534047b865474ea1d592986356403281 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 26 Sep 2026 00:14:18 +0800 Subject: [PATCH 15/30] refactor(2meet-data-optimizer): remove HPCT legacy from core - Feature flags keep only the live AddOn modules (wc_orders, latepoint) as ADDON_MODULES / addon_modules(); drop map_hpct_status() and the 7 HivePress HPCT modules whose interceptors were removed in PR-014 - Remove HPCT detection/notice (Compatibility, core), the admin HPCT import tab and status row, CLI import-hpct, the 7 advisor rules for dead modules, and the removed interceptors from COEXIST_WHITELIST - Verify: NoHpctLegacyTest guard; core unit 635 / integration 449, HP 145/49, WC 10/22 OK; all sites have every module idle and never imported HPCT - PR: PR-020c --- admin/class-tmdo-admin.php | 103 +----------------- admin/class-tmdo-help-tabs.php | 3 +- cli/class-tmdo-cli.php | 70 +----------- .../advisor/class-tmdo-module-detector.php | 1 - includes/advisor/class-tmdo-module-rules.php | 46 +------- includes/class-tmdo-compatibility.php | 59 +--------- includes/class-tmdo-core.php | 41 ------- includes/class-tmdo-feature-flags.php | 39 +------ includes/class-tmdo-hook-bus-bridge.php | 7 -- .../snapshots/class-tmdo-snapshot-manager.php | 2 +- tests/unit/Advisor/FSMAdvisorTest.php | 40 +++---- tests/unit/Advisor/ModuleDetectorTest.php | 16 +-- tests/unit/NoHpctLegacyTest.php | 61 +++++++++++ 13 files changed, 110 insertions(+), 378 deletions(-) create mode 100644 tests/unit/NoHpctLegacyTest.php diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php index ecc67bf..e11354d 100644 --- a/admin/class-tmdo-admin.php +++ b/admin/class-tmdo-admin.php @@ -783,11 +783,6 @@ class TMDO_Admin { $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. $needs_dot = array(); if ( class_exists( 'TMDO_Migration_Orchestrator' ) ) { @@ -811,7 +806,7 @@ class TMDO_Admin { ), 'wizards' => array( 'label' => esc_html__( '🧙 遷移精靈', '2meet-data-optimizer' ), - 'tabs' => array( 'migration-wizard', 'post-migration-wizard', 'hpct-import' ), + 'tabs' => array( 'migration-wizard', 'post-migration-wizard' ), ), 'stress' => array( 'label' => esc_html__( '⚡ 壓力測試', '2meet-data-optimizer' ), @@ -939,9 +934,6 @@ class TMDO_Admin { case 'rest-api': self::render_rest_api(); break; - case 'hpct-import': - self::render_hpct_import(); - break; case 'hivepress': if ( class_exists( 'TMDO_Admin_HivePress' ) ) { TMDO_Admin_HivePress::render(); @@ -1121,20 +1113,6 @@ class TMDO_Admin {
- - - -
Active' : 'N/A' ); ?>
- Imported'; - } elseif ( $compat['hpct_active'] ) { - echo 'Needs Import'; - } else { - echo 'N/A'; - } - ?> -
@@ -1169,11 +1147,11 @@ class TMDO_Admin {

-

HPCT Modules

+

AddOn Modules

- $state ) : ?> + $state ) : ?> @@ -2717,79 +2695,6 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'

' - . esc_html__( 'HPCT 匯入需要啟用 2meet-data-optimizer-hivepress-addon。', '2meet-data-optimizer' ) - . '

'; - 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 '

' . esc_html( $result->get_error_message() ) . '

'; - } else { - echo '

' . esc_html__( 'HPCT 匯入成功!建議停用 HP Custom Tables 外掛。', '2meet-data-optimizer' ) . '

'; - } - } - - $can_import = TMDO_HPCT_Import::can_import(); - $is_imported = TMDO_HPCT_Import::is_imported(); - - ?> -

- - -
-

-
- -

- - -

-
- - - - - - - - - - - - - - - - -
- -
- - -
- - -
-

-
- - console.log(r));' '; foreach ( $all as $m ) { printf( diff --git a/admin/class-tmdo-help-tabs.php b/admin/class-tmdo-help-tabs.php index 8aa3066..3da8749 100644 --- a/admin/class-tmdo-help-tabs.php +++ b/admin/class-tmdo-help-tabs.php @@ -107,7 +107,7 @@ class TMDO_Help_Tabs { private static function content_dashboard(): string { return '

' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '

' . '