Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e49e70ff | |||
| f929165b7b | |||
| 947e130386 | |||
| ca2cbdab65 | |||
| 7851f150fa | |||
| 1835c8776a | |||
| 1d70d13f45 | |||
| e26aadc4ec | |||
| 29adb05567 | |||
| 0aec4b9bb6 | |||
| eb1c8b78f4 | |||
| 06a3594417 | |||
| e18030b206 | |||
| 5968bcd95d | |||
| 303781e68b | |||
| d46913de41 | |||
| 1b50df7cd0 | |||
| a268470b60 | |||
| 1bdd51d9a3 | |||
| 22b46ad175 | |||
| b187c1c4d3 | |||
| ec8d69a6f4 | |||
| 28ac6e3cad | |||
| 582ff13a11 | |||
| edf9e6bc93 | |||
| fab07655a2 | |||
| 07955192d4 | |||
| 84f6e32913 | |||
| ad7acc40b7 | |||
| f48df1a33f | |||
| f15473527c | |||
| 9d1a7c5591 | |||
| a26500206a | |||
| 1b21ee9add | |||
| 6c782282c7 | |||
| ddefdc16d4 | |||
| 3a347269c8 | |||
| 02045c6f07 | |||
| 382786cb9d | |||
| 2022cf1d08 | |||
| 7807eae957 | |||
| 13ea341ef8 | |||
| 957f5c4ab0 | |||
| 336664473b | |||
| abbdb39acc | |||
| e8731c5119 | |||
| dc9a8e9886 | |||
| a7134bfb80 | |||
| 4fc59b757e | |||
| aa2da274dd | |||
| ec75721976 | |||
| f2e261c522 | |||
| b338925605 | |||
| 8c049f0cf2 | |||
| 77e338f9c3 | |||
| 781f171770 | |||
| b84252d787 | |||
| 08f9e313e0 | |||
| 20bd59ddae | |||
| 79a6c4d724 | |||
| f72a315986 | |||
| 2540d7c0e2 | |||
| b917e45c8c | |||
| 08492dd795 | |||
| ecf4ff4938 | |||
| 44a188b7ee | |||
| c70dc99bf9 | |||
| 10671e7b78 | |||
| 754f904e3b | |||
| aeca12b41d | |||
| f4eee2338b | |||
| 65c75dfc8d | |||
| d6b2e417f4 | |||
| d86c0a7bac | |||
| c84d2409b7 | |||
| 3d650eb848 | |||
| 0603537f0a | |||
| 8a1c9c0353 | |||
| 1eedaa9b3c | |||
| e0683a19b1 | |||
| 3f4fe52e1b | |||
| 1653011f4f | |||
| e9d8696c93 | |||
| 07db7734cf | |||
| 9dc4516ca0 | |||
| b407f235ac | |||
| fc0f018a94 | |||
| 151aadd661 | |||
| d3b72bd1af | |||
| 8fc1dc1f1c |
@@ -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.2
|
||||
* Version: 1.3.0
|
||||
* 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.2' );
|
||||
define( 'TMDO_VERSION', '1.3.0' );
|
||||
define( 'TMDO_DB_VERSION', '2.1.0' );
|
||||
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
||||
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
||||
@@ -234,6 +234,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
|
||||
// ── v2.0.0 Engine + Adapters + Modules ───────────────────────────────────
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
|
||||
@@ -258,14 +259,19 @@ 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 . 'includes/migration/class-tmdo-native-restorer.php';
|
||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.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' ) );
|
||||
require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php';
|
||||
require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
|
||||
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
|
||||
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
|
||||
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
|
||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
|
||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
|
||||
@@ -285,6 +291,12 @@ if ( is_admin() ) {
|
||||
TMDO_Setup_Wizard::register();
|
||||
require_once TMDO_PATH . 'admin/class-tmdo-dashboard-widget.php';
|
||||
TMDO_Dashboard_Widget::register();
|
||||
require_once TMDO_PATH . 'admin/class-tmdo-deprecated-addons-notice.php';
|
||||
TMDO_Deprecated_Addons_Notice::register();
|
||||
require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php';
|
||||
TMDO_Missing_Addons_Notice::register();
|
||||
require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php';
|
||||
TMDO_Promotion_Guard_Notice::register();
|
||||
}
|
||||
|
||||
// ── WP-CLI ─────────────────────────────────────────────────────────────────
|
||||
|
||||
+220
@@ -7,6 +7,226 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] — 2026-09-27 — 大表索引、路由計數與稽核修正(hardening 2.3)
|
||||
|
||||
**性質**:效能 + 稽核修正。無 schema 變更(宣告的複合索引以 ALTER ADD INDEX 就地加上)。
|
||||
搭配 HivePress AddOn 0.4.0、WooCommerce AddOn 0.3.0。來源:dev36 規模壓測(90 萬 listing)
|
||||
與系列稽核(2026-09-27)。
|
||||
|
||||
### Changed
|
||||
|
||||
- **group 宣告的複合索引只在 WP-CLI 或 WP-Cron 加上**:網頁請求(包括 auth_redirect 之前的
|
||||
`/wp-admin/` 與 `admin-post.php`)改排單次事件 `tmdo_ensure_group_indexes`;以 MySQL
|
||||
`GET_LOCK` 保證同一時間只有一個程序執行 ALTER;每個 ALTER 都成功才記錄已套用,失敗記
|
||||
`schema.index_failed` 並保留在 `doctor` 的 `[INDEX]` 待辦;新表在 CREATE TABLE 時就帶上
|
||||
索引。filter:`tmdo_schema_ddl_allowed`。dev36 90 萬列加兩個索引約 4.7 s。(PR-090a)
|
||||
- **路由搜尋的 found rows**:帶 `post__not_in` 的查詢(HivePress 每頁送一組隨機排除清單)
|
||||
改為「不含排除條件的 COUNT(快取)」減去「排除清單中符合條件的篇數(主鍵查詢)」,結果
|
||||
精確,快取不再被打散。(PR-091)快取 key 另外去掉 wpdb 替 `%` 產生的 placeholder escape
|
||||
(每個請求不同,關鍵字搜尋原本永遠不命中,也就是 2.2 驗收「關鍵字 + 篩選 routed 較慢」的
|
||||
原因)(PR-116);計數改跟隨自有的 `tmdo_posts` last_changed(文章儲存/刪除、經 Hook Bus
|
||||
的扁平寫入)與 terms 的 last_changed,不再跟隨 posts 的 last_changed——任何 post meta 寫入
|
||||
都會推動它,dev36 的 Blocksy 佈景在爬蟲瀏覽每篇 listing 時都寫一筆 meta,快取幾秒就失效
|
||||
(PR-117)。dev36 關鍵字 + 價格 routed 9.8 s → 首次後約 5.5 s(原生 5.0–6.2 s,剩餘為
|
||||
WordPress 核心的 LIKE 全表掃描)。
|
||||
- `POST /wpdo/v1/listings/{id}/view` 被限流擋下時,`wpdo_rl_stats` 改為 1/10 抽樣寫入
|
||||
(每次 +10),匿名請求不再 1:1 放大成 option 寫入。(PR-094)
|
||||
- `TMDO_Logger::info()` 只在 `WP_DEBUG_LOG` 時寫 error_log(filter `tmdo_log_info`);
|
||||
`notice()` 一律寫入,模式切換(`bridge_mode_changed`)與快照刪除改用它,保留操作紀錄;
|
||||
`debug()`、`warning()`、`error()` 不變。(PR-095、PR-113)
|
||||
- 公開 listings REST 一律不輸出名稱像 email、phone、address、IP 的欄位,即使宣告
|
||||
`show_in_rest`。(PR-096)
|
||||
- **既有扁平表的 schema 升級只在 WP-CLI 或 WP-Cron 執行**:網頁請求遇到定義變更時記入
|
||||
`tmdo_schema_deferred` 並排 `tmdo_ensure_group_indexes`;升級前該 group 的 key 走原生
|
||||
(Hook Bus 不攔、router 不路由),升級後排 `tmdo_drift_resync` 補回期間跳過的扁平寫入。
|
||||
新表照常立即建立;aeav_only 仍立即升級(扁平表是唯一副本);`doctor` 顯示 `[SCHEMA]`。(PR-107)
|
||||
驗收審查後補強:既有表的升級與索引共用同一把 `GET_LOCK`(匿名 `/wp-cron.php` 也會通過
|
||||
`ddl_allowed()`),取得鎖後重新比對 hash;延後的 group 要等 drift fix 完成才改回扁平表;
|
||||
寫入扁平表的 entity 新建 group 表時同樣先走原生,由 drift fix 從原生填滿(新宣告的
|
||||
HivePress 屬性不會把搜尋導到空表);定義還原時解除延後;cron 事件本身也會升級延後的表
|
||||
(`ALTERNATE_WP_CRON`);dbDelta 之後每個欄位都存在才記錄定義(否則 `schema.upgrade_failed`
|
||||
並重試);延後期間 `direct_read()` 回傳原生值。(PR-111、PR-114)
|
||||
- 讀寫分離(HyperDB、LudicrousDB)時,取索引鎖前先 `send_reads_to_masters()`,
|
||||
`GET_LOCK` 才會在 primary 上。(PR-108)
|
||||
- Cold 快取的「清除」改為遞增版本號(`wpdo_cold_cache_version`),不再呼叫
|
||||
`wp_cache_flush_group()`(Redis Object Cache 以伺服器端腳本掃描整個 group,大 group 會卡住
|
||||
Redis);舊 key 依 TTL 過期。(PR-110)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 後台主頁與設定精靈第 4 步的表單改在 `load-{page}`(輸出前)處理,`wp_safe_redirect()`
|
||||
可以正常生效;精靈快照失敗改以 `snap_failed=1` 導回。(PR-097)
|
||||
- `uninstall.php` 的全域變數加上 `$wpdo_` 前綴(WordPress.org Plugin Check)。(PR-098)
|
||||
- 扁平表已被刪除的 group 不再留在 `tmdo_post_status_columns`:每次文章狀態變更都 UPDATE
|
||||
不存在的表並印出 DB error。(PR-106)
|
||||
|
||||
### Security(已驗證,無程式變更)
|
||||
|
||||
- 快照目錄在 nginx 下的外洩風險(稽核 Medium):快照檔名含 64-bit 隨機值、遷移備份檔名含
|
||||
12 字元隨機 job id,目錄有 index.php 擋列表;`doctor` 的 HTTP 探測與 nginx 後台提示已存在。
|
||||
dev31 探測全為 403。不採用「目錄名加隨機後綴」:各站 nginx deny 規則比對 `wpdo-backups/`,
|
||||
改名反而讓新快照脫離保護。(PR-093)
|
||||
|
||||
### 升級後
|
||||
|
||||
- 部署後第一個 WP-CLI 指令或 WP-Cron 會為 HivePress AddOn 0.4.0 宣告的索引執行 ALTER
|
||||
(90 萬列約 5 s);`DISABLE_WP_CRON` 且沒有系統 cron 的站,執行一次 `wp tmdo doctor` 即可。
|
||||
- CLI 輸出不再夾帶 `[WPDO][INFO]`;要看請定義 `WP_DEBUG_LOG` 或加上 `tmdo_log_info` filter。
|
||||
- 已知限制:cold 快取版本遞增的瞬間,在遞增前就開始的長請求寫入後只會清掉舊版 key,新版 key
|
||||
最多保留舊值到 TTL(預設 1 小時);cold module 預設 idle。
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] — 2026-09-27 — 漂移偵測、範圍收斂、CLI 與診斷(hardening 2.2)
|
||||
|
||||
**性質**:功能 + 資料正確性修正。無 schema 變更。延續 ADR-004 的搜尋索引模式;搭配
|
||||
HivePress AddOn 0.3.0、WooCommerce AddOn 0.2.0。
|
||||
|
||||
### Added
|
||||
|
||||
- **`wp tmdo drift-check <entity> [--group] [--sample=<n>] [--format]`**(`TMDO_Drift_Check`):
|
||||
逐 key 比對扁平表與原生 meta 的值(第一筆原生 row → 安全 unserialize →
|
||||
`Type_Caster::to_db`),回報 miss / diff / extra。序列化 vs JSON、`50` vs `50.000000`
|
||||
不算漂移;超過 255 字元存 NULL 的 text 不算 miss。結果寫入 `wpdo_drift_status`。(PR-070)
|
||||
- **`drift-check --fix`**:刪除已刪實體與非本 group post type 的扁平列,其餘漂移列經
|
||||
`TMDO_Entity_Migration_Engine::migrate_entity()` 從原生重建,清除列快取後再檢查一次。
|
||||
不寫原生;aeav_only(扁平才是真實資料)一律拒絕。(PR-071、PR-073b)
|
||||
- **每日抽樣**:`wpdo_daily_health_check` 對 dual_write 以上、非 aeav_only 的 entity 每個
|
||||
group 抽 500 筆,結果取代既有紀錄。(PR-073)
|
||||
- **Hook Bus 關閉期間的寫入補回**:entity 從 disabled 進入 dual_write / shadow_read,或
|
||||
Hook Bus flag 由關轉開時,60 秒後以單次 WP-Cron `tmdo_drift_resync` 執行全量
|
||||
`drift-check --fix`(不做 `post_modified_gmt` 增量:WooCommerce 直接 SQL 改庫存不會更新
|
||||
post_modified)。停用 / 解除安裝時清除排程。(PR-074)
|
||||
- **`wp tmdo bridge-mode <entity> <mode>`**:經 `TMDO_Mode_Manager::set()`,受
|
||||
Promotion Guard 約束;被拒時 exit 1、模式不變。(PR-069)
|
||||
- **`wp tmdo member-backfill --all`**:回填所有已註冊的 user group。(PR-068)
|
||||
- **`wp tmdo cleanup-hp-transients --options [--dry-run|--confirm] [--batch=<n>]`**:
|
||||
刪除 HivePress AddOn 0.3.0 之前寫進 wp_options 的 `_transient_wpdo_hp_pm_*`(含 timeout),
|
||||
分批刪除(預設 5000),AddOn 未啟用也可執行。dev36 有 2,314,750 列。(PR-080)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Post query router 避開漂移中的 group**:上次 drift-check 有 miss / diff / extra 的
|
||||
group 不路由,整個查詢維持原生(全有或全無);從未檢查的 group 照常路由;aeav_only
|
||||
不受影響。(PR-072、PR-073b)
|
||||
- **純排序搜尋 flat-first**:沒有路由篩選的 HivePress `_sort` 搜尋,在該 post type 每篇都有
|
||||
扁平列時改由扁平表驅動(`STRAIGHT_JOIN` + INNER JOIN);Router 在 `wp_insert_post`
|
||||
替新文章建空列,覆蓋率以 anti-join 檢查並快取 15 分鐘(隨 post 模式變更失效)。
|
||||
flat-first 且扁平表是唯一 JOIN 時拿掉 WordPress 為 tax query 加的 `GROUP BY posts.ID`。
|
||||
dev36 `rating__desc`:原生約 3.3 s、1.1.0 約 1.5–1.9 s、現在約 0.51–0.85 s。(PR-079)
|
||||
- **Auto-promoter** 的第三個條件改為 `TMDO_Drift_Check::clean_since()`:2 天內每個 group 都
|
||||
檢查過(抽樣或全量)且沒有漂移;沒有紀錄就不升級。(PR-075)
|
||||
- **shadow_read diff 節流**:每個 entity type + key 每小時最多記 20 列(疊加既有的
|
||||
每物件 5 分鐘視窗)。(PR-075)
|
||||
- **回填 group 改讀 registry**:admin「全部回填」、`post-migrate-group`、`member-backfill`、
|
||||
`member-audit` 取所有已註冊 group(排除已還原的孤兒 group),不再是寫死清單;含 json
|
||||
欄位的 group(attachment、nav_menu_item)逐列經 migration engine。(PR-067、PR-068)
|
||||
- **不再註冊 `post/wc_product`**:WooCommerce 直接讀 product meta、以 SQL 寫庫存,扁平副本
|
||||
必然漂移(ADR-004 決策 2)。既有的 `wp_wpdo_post_wc_product` 表保留,以
|
||||
`wp tmdo bridge-restore-native post --group=wc_product --retire` 封存;解除安裝仍會刪除。(PR-077)
|
||||
- `member-sso-sync`、`member-force-logout` 標示為 deprecated(`sso` group 已不註冊)。(PR-069)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Entity 回填替沒有原生 row 的 key 寫入 DDL 預設值(`session_count` / `account_flags` = 0),
|
||||
每個 user 都被捏造出 0;現在寫 NULL,required NOT NULL 欄位保留預設值。(PR-066)
|
||||
- `member-cutover` 在轉換被拒時 fatal(把 `WP_Error` 當陣列)。(PR-068)
|
||||
- `doctor` 依 registry 檢查 entity 表(不再因從未註冊的 `sso` 表而每站失敗),並顯示
|
||||
Promotion Guard 違規與 drift 狀態;`term-comment-diagnose` 依各 filter 的 `is_enabled()`
|
||||
顯示狀態;`bridge-status` 只計有值的扁平列、eav_ids 限定 group 的 post type
|
||||
(dev36 wp_core 15306% → 100%)。(PR-076)
|
||||
- 2.1 稽核:公開 REST route 註明公開理由;admin 23 處三元 echo 依情境 `esc_attr` /
|
||||
`esc_html`;migration engine 與 health cron 的例外改記 `TMDO_Logger`。(PR-078)
|
||||
|
||||
### Docs
|
||||
|
||||
- ADR-004「設計(Release 2.2)」、`CONTEXT.md` Entity Bridge 的漂移段落。(PR-086)
|
||||
|
||||
### 升級後
|
||||
|
||||
- HivePress 站:部署 AddOn 0.3.0 後執行 `wp tmdo cleanup-hp-transients --options --confirm`。
|
||||
- 曾註冊 wc_product 的站:`wp tmdo bridge-restore-native post --group=wc_product --retire`。
|
||||
- 已進入 dual_write 以上的站:`wp tmdo drift-check <entity>` 建立第一筆紀錄(未檢查的
|
||||
group 照常路由)。
|
||||
|
||||
---
|
||||
|
||||
## [1.1.1] — 2026-09-26 — Security:公開 REST 不再回傳私有欄位
|
||||
|
||||
**性質**:安全修正(Critical)。1.0.x 起即存在;1.1.0 zip 已撤回(`dist/withdrawn/`)。
|
||||
|
||||
### Security
|
||||
|
||||
- **公開的 `GET /wpdo/v1/listings`、`/wpdo/v1/listings/{id}` 會把所有已註冊的 hot / cold
|
||||
欄位回傳給未登入者**:`TMDO_Schema_Registry::register()` 的 `show_in_rest` 預設為 true,
|
||||
而 addon 都沒有設定它。dev36 實測可取得 infocards 的 `tmeetic_ig_access_token_enc`、
|
||||
`tmeetic_ig_token_expiry`、`tmeetic_ig_user_id`,以及 WooCommerce 的 `_purchase_note`。
|
||||
- `show_in_rest` 預設改為 **false**:要公開的欄位必須明確宣告。
|
||||
- 受保護的 meta key(底線開頭)與名稱像憑證的 key(token、secret、password、api key、
|
||||
private key、credential、salt、nonce)即使宣告公開也一律不輸出
|
||||
(`TMDO_Schema_Registry::rest_exposable()`)。
|
||||
- Zone A 列表路徑在沒有任何公開欄位時不再回傳整列(fail-closed);篩選參數與 orderby
|
||||
只接受公開欄位,避免藉由篩選 / 排序推測隱藏值。
|
||||
- **相容性**:依賴預設公開的欄位,現在需在註冊時加上 `'show_in_rest' => true`,或使用
|
||||
既有的 `tmdo_rest_listing_visible_fields` filter。
|
||||
- 測試:`RestApiTest` 新增 3 個;live suite `rest`(未登入 GET 不得出現私有 / 憑證類 key)。
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] — 2026-09-26 — HivePress / WooCommerce 搜尋索引模式(hardening 2.1)
|
||||
|
||||
**性質**:資料正確性修正 + 查詢改寫補強。無 schema 變更。決策與設計見
|
||||
`docs/adr-004-search-index-mode-for-hp-wc.md`;搭配 HivePress AddOn 0.2.0。
|
||||
|
||||
### Added
|
||||
|
||||
- **Promotion Guard**(`TMDO_Promotion_Guard`):HivePress 啟用時 post / user / term /
|
||||
comment、WooCommerce 啟用時 post / user 不得升到 aeav_only 或執行 cleanup
|
||||
(`WP_Error( 'promotion_guarded' )`,filter `tmdo_promotion_guard_allow`);已在
|
||||
aeav_only 的站只顯示後台 notice。(PR-053)
|
||||
- **`wp tmdo repair-array-literals [--entity] [--confirm]`**:把扁平表中的 `"Array"`
|
||||
從原生修回;原生也被污染的列出清單。(PR-055)
|
||||
- **`bridge-restore-native --overwrite / --only-missing`**:預設依模式(aeav_only 覆寫、
|
||||
其他只補缺);報告新增 `missing` / `differs` / `same` / `default_skipped`。(PR-058)
|
||||
- **live 測試層** `tests/live/`(在真實站台以 `wp eval-file` 執行):suites
|
||||
`shadow_write`、`serialized`、`resync`、`router`、`zero_side_effect`,以及 qbench /
|
||||
fe.sh 等量測工具。(PR-052)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hook Bus 寫入順序**:dual_write / shadow_read 由 WordPress 寫原生,扁平表在
|
||||
`added_ / updated_ / deleted_{type}_meta` 從原生重新同步(含 `delete_all`、
|
||||
`update_metadata_by_mid` 改 key)。(PR-057)
|
||||
- **shadow_read** 讀原生,並抽樣比對扁平值(`tmdo_shadow_compare_sample_rate`)。(PR-056)
|
||||
- **Post query router**:
|
||||
- 全有或全無:OR、多 post type、含已註冊 key 的巢狀 group、type 語意與欄位不符的
|
||||
clause 讓整個查詢維持原生;LIKE 包 `%esc_like%`;`pre_get_posts` priority 1000。(PR-060)
|
||||
- 每個 group 各自 JOIN、不加 GROUP BY;NOT EXISTS 不走狀態 / 日期副本路由。(PR-061)
|
||||
- meta orderby:HivePress `_sort`(NOT EXISTS + EXISTS)形狀、具名 clause、
|
||||
`meta_key` + `meta_value_num`。(PR-062)
|
||||
- routed 搜尋維持 WordPress 的 split query(ID-first)。(PR-062b)
|
||||
- 依扁平欄位排序且有篩選的搜尋改由扁平表驅動(`STRAIGHT_JOIN`,posts.ID tie-break 改讀等值的
|
||||
`post_id`),不再以 temporary table 排序;filter `tmdo_router_flat_first` 可關閉。(PR-062c)
|
||||
- `composer phpcs` 改用 `phpcs.xml`(與 CI 相同)。
|
||||
|
||||
### Fixed
|
||||
|
||||
- shadow_read 下 `update_*_meta()` 回傳 false、原生沒有寫入(資料遺失)。(PR-056)
|
||||
- 陣列 / 物件存進 text 欄位變成 `"Array"`;text 超過 255 字元改存 NULL 並記錄
|
||||
`text_overflow`。(PR-055)
|
||||
- `bridge-restore-native` 覆蓋較新的原生值(WC 庫存)、替每個 user 捏造預設值列。(PR-058)
|
||||
- Cold / Hot 表不存在時,`save_post`、`loop_start`、刪除文章仍查詢它們(DB error)。(PR-054、PR-054b)
|
||||
- 刪除 post / user / term / comment 後,扁平表留下全 NULL 的孤兒列(PR-057 的 NULL 同步會重建已刪的列)。(PR-057b)
|
||||
- Router:Q05(wp_core 與另一 group 的 key 並用)`Unknown column`;OR / 多 post type /
|
||||
無 type 數值比較 / 具名 orderby 的結果與原生不同。(PR-060〜062)
|
||||
|
||||
### Docs
|
||||
|
||||
- ADR-004、`CONTEXT.md` Entity Bridge(寫入順序、shadow_read、搜尋索引模式、Router 規則)、
|
||||
`docs/BENCHMARK-SCALE.md` 前台與寫入鏈實測。(PR-064)
|
||||
|
||||
---
|
||||
|
||||
## [1.0.2] — 2026-08-15 — NinjaFirewall 相容性:WAF 設定選項保護
|
||||
|
||||
**性質**:防禦性修正 + 文件。無 schema 變更,無破壞性變更。
|
||||
|
||||
+30
@@ -135,6 +135,36 @@ Newer anti-EAV system (v2.5+) covering user, term, comment, and post entities vi
|
||||
The Entity Bridge and the Zone system coexist; the Sync Bridge's
|
||||
`is_owned_by_entity_bridge()` guard prevents duplicate writes.
|
||||
|
||||
**Write order (v1.1.0).** In `dual_write` / `shadow_read` the Hook Bus pre-filters
|
||||
only decide the route and return `null`: WordPress writes native meta itself, and the
|
||||
flat value is then re-synced *from native* in `added_/updated_/deleted_{type}_meta`
|
||||
(priority 1) — `TMDO_Hook_Bus::sync_from_native()` reads the first native row with
|
||||
direct SQL; no native row → flat NULL. `aeav_only` keeps the pre-filter short-circuit.
|
||||
|
||||
**shadow_read** reads native meta (WordPress meta cache) and compares a sample of reads
|
||||
with the flat value (`tmdo_shadow_compare_sample_rate`, default 0.1). Flat tables serve
|
||||
queries only: `TMDO_Post_Query_Router` is active from `shadow_read` up.
|
||||
|
||||
**Search-index mode** (ADR-004). On HivePress / WooCommerce sites native meta is the
|
||||
source of truth and flat tables are its search index: `TMDO_Promotion_Guard` refuses
|
||||
`aeav_only` and cleanup there (post/user/term/comment with HivePress, post/user with
|
||||
WooCommerce), because both read meta with an empty key and WooCommerce updates stock
|
||||
and sales with direct SQL. Override: filter `tmdo_promotion_guard_allow`.
|
||||
|
||||
**Post query router rules.** A query is rewritten only when the flat table returns
|
||||
what native `meta_query` would: every registered clause routable (else the whole query
|
||||
stays native), AND relation, one post type, column type matching the clause's CAST
|
||||
semantics, LIKE as `%esc_like%`. Meta ordering (the HivePress `_sort` NOT EXISTS/EXISTS
|
||||
shape, a named clause, `meta_key` + `meta_value_num`) is routed only as the sole
|
||||
`orderby` on a column that orders like native.
|
||||
|
||||
**Drift** (v1.2.0). `TMDO_Drift_Check` compares flat values with native meta per key
|
||||
(miss / diff / extra) and records the outcome per group in `wpdo_drift_status`;
|
||||
`--fix` rebuilds flat rows from native. The router routes no clause of a group recorded
|
||||
as drifting (never checked → routes). A daily sample, and a full fix 60 s after an
|
||||
entity leaves `disabled` or the Hook Bus is switched back on (`tmdo_drift_resync`), keep
|
||||
the record current. None of this applies in `aeav_only`, where flat is the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Migration Phase (Strategy pattern)
|
||||
|
||||
+48
-123
@@ -89,13 +89,16 @@ class TMDO_Admin {
|
||||
* Register the admin menu page under Tools.
|
||||
*/
|
||||
public static function register_menu(): void {
|
||||
add_management_page(
|
||||
$hook_suffix = add_management_page(
|
||||
__( 'WP Data Optimizer', '2meet-data-optimizer' ),
|
||||
__( 'WP Data Optimizer', '2meet-data-optimizer' ),
|
||||
'manage_options',
|
||||
self::MENU_SLUG,
|
||||
array( __CLASS__, 'render_page' )
|
||||
);
|
||||
if ( $hook_suffix ) {
|
||||
add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_post' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,9 +329,10 @@ class TMDO_Admin {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the admin page.
|
||||
* Handle the page's form posts on load-{page}, before any output, so their
|
||||
* redirects work (inside render_page() the admin header was already sent).
|
||||
*/
|
||||
public static function render_page(): void {
|
||||
public static function handle_post(): void {
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
return;
|
||||
}
|
||||
@@ -556,11 +560,11 @@ class TMDO_Admin {
|
||||
exit;
|
||||
}
|
||||
|
||||
// v2.10.0: Post Migration Wizard — backfill all 7 groups.
|
||||
// v2.10.0: Post Migration Wizard — backfill every registered post group.
|
||||
if ( isset( $_POST['wpdo_post_backfill_all'] ) && check_admin_referer( 'wpdo_post_backfill_all' ) && class_exists( 'TMDO_Post_Migration' ) ) {
|
||||
$total_migrated = 0;
|
||||
$errors = array();
|
||||
foreach ( array( 'wp_core', 'attachment', 'wc_product', 'hp_listing_core', 'hp_request_core', 'hp_vendor_core', 'nav_menu_item' ) as $group ) {
|
||||
foreach ( TMDO_Post_Migration::backfillable_groups() as $group ) {
|
||||
try {
|
||||
$result = TMDO_Post_Migration::backfill_group( $group );
|
||||
$total_migrated += (int) $result['migrated'];
|
||||
@@ -752,6 +756,15 @@ class TMDO_Admin {
|
||||
);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the admin page.
|
||||
*/
|
||||
public static function render_page(): void {
|
||||
if ( ! TMDO_Capability::current_user_can_admin() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tabs = array(
|
||||
'dashboard' => __( '儀表板', '2meet-data-optimizer' ),
|
||||
@@ -774,7 +787,7 @@ class TMDO_Admin {
|
||||
'hivepress' => __( 'HivePress 整合', '2meet-data-optimizer' ),
|
||||
);
|
||||
|
||||
$tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) );
|
||||
$tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- tab navigation, no state change.
|
||||
// v2.11.8: legacy tabs removed — redirect bookmarks to current main maintenance entry.
|
||||
if ( in_array( $tab, array( 'sop', 'migration' ), true ) ) {
|
||||
$tab = 'entity-bridge';
|
||||
@@ -783,11 +796,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 +819,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' ),
|
||||
@@ -851,8 +859,8 @@ class TMDO_Admin {
|
||||
<span class="wpdo-tab-group-label" aria-hidden="true"><?php echo esc_html( $group['label'] ); ?></span>
|
||||
<?php foreach ( $group_tabs as $slug ) : ?>
|
||||
<a href="<?php echo esc_url( add_query_arg( 'tab', $slug, admin_url( 'tools.php?page=' . self::MENU_SLUG ) ) ); ?>"
|
||||
class="nav-tab <?php echo $tab === $slug ? 'nav-tab-active' : ''; ?>"
|
||||
<?php echo $tab === $slug ? 'aria-current="page"' : ''; ?>>
|
||||
class="nav-tab <?php echo esc_attr( $tab === $slug ? 'nav-tab-active' : '' ); ?>"
|
||||
aria-current="<?php echo esc_attr( $tab === $slug ? 'page' : 'false' ); ?>">
|
||||
<?php echo esc_html( $tabs[ $slug ] ); ?>
|
||||
<?php if ( isset( $needs_dot[ $slug ] ) ) : ?>
|
||||
<span class="wpdo-tab-dot" title="
|
||||
@@ -939,9 +947,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();
|
||||
@@ -1107,7 +1112,7 @@ class TMDO_Admin {
|
||||
<tbody>
|
||||
<tr>
|
||||
<th><?php esc_html_e( '資料庫引擎', '2meet-data-optimizer' ); ?></th>
|
||||
<td><code><?php echo TMDO_IS_MYSQL ? 'MySQL' : 'SQLite'; ?></code></td>
|
||||
<td><code><?php echo esc_html( TMDO_IS_MYSQL ? 'MySQL' : 'SQLite' ); ?></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?php esc_html_e( '外掛版本', '2meet-data-optimizer' ); ?></th>
|
||||
@@ -1121,24 +1126,14 @@ class TMDO_Admin {
|
||||
<th><?php esc_html_e( 'HivePress', '2meet-data-optimizer' ); ?></th>
|
||||
<td><?php echo wp_kses_post( $compat['hivepress'] ? '<span class="wpdo-badge wpdo-badge-ok">Active</span>' : '<span class="wpdo-badge">N/A</span>' ); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'HP Custom Tables', '2meet-data-optimizer' ); ?></th>
|
||||
<td>
|
||||
<?php
|
||||
if ( $compat['hpct_active'] && $compat['hpct_imported'] ) {
|
||||
echo '<span class="wpdo-badge wpdo-badge-ok">Imported</span>';
|
||||
} elseif ( $compat['hpct_active'] ) {
|
||||
echo '<span class="wpdo-badge wpdo-badge-warn">Needs Import</span>';
|
||||
} else {
|
||||
echo '<span class="wpdo-badge">N/A</span>';
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Object Cache', '2meet-data-optimizer' ); ?></th>
|
||||
<td>
|
||||
<?php echo wp_using_ext_object_cache() ? '<span class="wpdo-badge wpdo-badge-ok">External</span>' : '<span class="wpdo-badge">Built-in</span>'; ?>
|
||||
<?php if ( wp_using_ext_object_cache() ) : ?>
|
||||
<span class="wpdo-badge wpdo-badge-ok">External</span>
|
||||
<?php else : ?>
|
||||
<span class="wpdo-badge">Built-in</span>
|
||||
<?php endif; ?>
|
||||
<?php if ( $cache['flush_support'] ) : ?>
|
||||
<span class="wpdo-badge wpdo-badge-ok">Flush Support</span>
|
||||
<?php endif; ?>
|
||||
@@ -1169,11 +1164,11 @@ class TMDO_Admin {
|
||||
<h2><?php esc_html_e( '模組狀態', '2meet-data-optimizer' ); ?></h2>
|
||||
<div class="wpdo-grid">
|
||||
<div>
|
||||
<h3>HPCT Modules</h3>
|
||||
<h3>AddOn Modules</h3>
|
||||
<table class="widefat striped">
|
||||
<thead><tr><th><?php esc_html_e( '模組', '2meet-data-optimizer' ); ?></th><th><?php esc_html_e( '狀態', '2meet-data-optimizer' ); ?></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ( TMDO_Feature_Flags::hpct_modules() as $module => $state ) : ?>
|
||||
<?php foreach ( TMDO_Feature_Flags::addon_modules() as $module => $state ) : ?>
|
||||
<tr>
|
||||
<td><code><?php echo esc_html( $module ); ?></code></td>
|
||||
<td><span class="wpdo-state wpdo-state-<?php echo esc_attr( $state ); ?>"><?php echo esc_html( $state ); ?></span></td>
|
||||
@@ -1748,13 +1743,13 @@ class TMDO_Admin {
|
||||
↑ <?php esc_html_e( '升級模式', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button wpdo-btn-demote <?php echo $prev_mode ? '' : 'wpdo-eb-btn-dim'; ?>"
|
||||
class="button wpdo-btn-demote <?php echo esc_attr( $prev_mode ? '' : 'wpdo-eb-btn-dim' ); ?>"
|
||||
data-wpdo-action="demote"
|
||||
data-entity-type="<?php echo esc_attr( $etype ); ?>"
|
||||
data-prev-mode="<?php echo esc_attr( $prev_mode ?? '' ); ?>"
|
||||
<?php disabled( ! $prev_mode ); ?>
|
||||
title="<?php echo esc_attr( $prev_mode ? '降級到 ' . $prev_mode : '已在最低模式' ); ?>"
|
||||
class="<?php echo $prev_mode ? '' : 'wpdo-eb-btn-dim'; ?>">
|
||||
class="<?php echo esc_attr( $prev_mode ? '' : 'wpdo-eb-btn-dim' ); ?>">
|
||||
↓ <?php esc_html_e( '降級模式', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2059,7 +2054,7 @@ class TMDO_Admin {
|
||||
</div>
|
||||
|
||||
<!-- Progress section (live) -->
|
||||
<div id="wpdo-st-progress-card" class="wpdo-eb-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
|
||||
<div id="wpdo-st-progress-card" class="wpdo-eb-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
|
||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||
<div class="wpdo-bar-section">
|
||||
<div class="wpdo-bar-header">
|
||||
@@ -2093,7 +2088,7 @@ class TMDO_Admin {
|
||||
</div>
|
||||
|
||||
<!-- Benchmark report -->
|
||||
<div id="wpdo-st-bench-card" class="wpdo-eb-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
|
||||
<div id="wpdo-st-bench-card" class="wpdo-eb-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
|
||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||
<div id="wpdo-st-bench-content">
|
||||
<?php if ( ! empty( $state['benchmark'] ) ) : ?>
|
||||
@@ -2717,79 +2712,6 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* HPCT Import tab: preview and execute import.
|
||||
*/
|
||||
private static function render_hpct_import(): void {
|
||||
// HPCT is a HivePress-family plugin; the importer ships in that AddOn.
|
||||
if ( ! class_exists( 'TMDO_HPCT_Import' ) ) {
|
||||
echo '<div class="notice notice-warning"><p>'
|
||||
. esc_html__( 'HPCT 匯入需要啟用 2meet-data-optimizer-hivepress-addon。', '2meet-data-optimizer' )
|
||||
. '</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle import action.
|
||||
if ( isset( $_POST['wpdo_run_import'] ) && check_admin_referer( 'wpdo_hpct_import' ) ) {
|
||||
$result = TMDO_HPCT_Import::run();
|
||||
if ( is_wp_error( $result ) ) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html( $result->get_error_message() ) . '</p></div>';
|
||||
} else {
|
||||
echo '<div class="notice notice-success"><p>' . esc_html__( 'HPCT 匯入成功!建議停用 HP Custom Tables 外掛。', '2meet-data-optimizer' ) . '</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
$can_import = TMDO_HPCT_Import::can_import();
|
||||
$is_imported = TMDO_HPCT_Import::is_imported();
|
||||
|
||||
?>
|
||||
<h2><?php esc_html_e( 'HP Custom Tables 匯入', '2meet-data-optimizer' ); ?></h2>
|
||||
|
||||
<?php if ( $is_imported ) : ?>
|
||||
<div class="notice notice-success inline">
|
||||
<p><?php esc_html_e( 'HPCT 設定已匯入完成。如果 HP Custom Tables 外掛仍然啟用,建議停用它。', '2meet-data-optimizer' ); ?></p>
|
||||
</div>
|
||||
<?php elseif ( $can_import ) : ?>
|
||||
<p class="description"><?php esc_html_e( '偵測到 HP Custom Tables 外掛。匯入會將 HPCT 的模組狀態和遷移記錄複製到 WPDO,然後由 WPDO 接管所有攔截器。', '2meet-data-optimizer' ); ?></p>
|
||||
|
||||
<?php
|
||||
$preview_data = TMDO_HPCT_Import::preview();
|
||||
if ( ! empty( $preview_data['modules'] ) ) :
|
||||
$preview = $preview_data['modules'];
|
||||
?>
|
||||
<h3><?php esc_html_e( '匯入預覽', '2meet-data-optimizer' ); ?></h3>
|
||||
<table class="widefat striped wpdo-table--narrow">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'HPCT 模組', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'HPCT 狀態', '2meet-data-optimizer' ); ?></th>
|
||||
<th><?php esc_html_e( 'WPDO 狀態', '2meet-data-optimizer' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $preview as $item ) : ?>
|
||||
<tr>
|
||||
<td><code><?php echo esc_html( $item['module'] ); ?></code></td>
|
||||
<td><?php echo esc_html( $item['hpct_status'] ); ?></td>
|
||||
<td><span class="wpdo-state wpdo-state-<?php echo esc_attr( $item['wpdo_state'] ); ?>"><?php echo esc_html( $item['wpdo_state'] ); ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form method="post" class="wpdo-mt-3">
|
||||
<?php wp_nonce_field( 'wpdo_hpct_import' ); ?>
|
||||
<?php submit_button( __( '執行匯入', '2meet-data-optimizer' ), 'primary', 'wpdo_run_import', false ); ?>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php else : ?>
|
||||
<div class="notice notice-info inline">
|
||||
<p><?php esc_html_e( '未偵測到 HP Custom Tables 外掛,或已完成匯入。', '2meet-data-optimizer' ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
}
|
||||
|
||||
// ── AJAX handler ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -2990,7 +2912,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
||||
?>
|
||||
<h2><?php esc_html_e( '健康檢查(Doctor)', '2meet-data-optimizer' ); ?></h2>
|
||||
<p class="description">
|
||||
<?php esc_html_e( '7 項自我診斷檢查:schema 完整性、錯誤預算、hook 衝突、autoload 大小、postmeta 爆量、orphan zone rows、缺少快照。', '2meet-data-optimizer' ); ?>
|
||||
<?php esc_html_e( '10 項自我診斷檢查:schema 完整性、錯誤預算、hook 衝突、autoload 大小、postmeta 爆量、orphan zone rows、缺少快照、實體群組名稱衝突、孤兒實體群組、缺少對應 AddOn。', '2meet-data-optimizer' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<a class="button" href="<?php echo esc_url( admin_url( 'site-health.php' ) ); ?>">
|
||||
@@ -3004,13 +2926,16 @@ 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' ) ),
|
||||
'wpdo_orphaned_entity_groups' => array( 'check_orphaned_entity_groups', __( '孤兒實體群組', '2meet-data-optimizer' ) ),
|
||||
'wpdo_missing_integration_addons' => array( 'check_missing_integration_addons', __( '缺少對應 AddOn', '2meet-data-optimizer' ) ),
|
||||
);
|
||||
?>
|
||||
<table class="widefat striped">
|
||||
@@ -3490,7 +3415,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
||||
|
||||
<?php
|
||||
// v2.12.1: Term + Comment garbage write-time filter toggle + status.
|
||||
$tcg_enabled = (bool) get_option( 'wpdo_term_comment_garbage_filter_enabled', '1' );
|
||||
$tcg_enabled = (bool) get_option( 'wpdo_term_comment_garbage_filter_enabled', '0' );
|
||||
$tcg_drops_24h = class_exists( 'TMDO_Term_Comment_Garbage_Filter' )
|
||||
? TMDO_Term_Comment_Garbage_Filter::get_drop_count_24h()
|
||||
: 0;
|
||||
@@ -3653,7 +3578,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
||||
|
||||
<?php
|
||||
// v2.12.4: Term + Comment misc bucket toggle + status.
|
||||
$misc_enabled = (bool) get_option( 'wpdo_term_comment_misc_bucket_enabled', '1' );
|
||||
$misc_enabled = (bool) get_option( 'wpdo_term_comment_misc_bucket_enabled', '0' );
|
||||
$term_misc_rows = class_exists( 'TMDO_Term_Comment_Misc_Bucket' )
|
||||
? TMDO_Term_Comment_Misc_Bucket::count_rows( 'term' )
|
||||
: 0;
|
||||
@@ -3738,7 +3663,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
||||
<?php
|
||||
$blacklist = class_exists( 'TMDO_FSM_Automator' ) ? TMDO_FSM_Automator::blacklist() : array();
|
||||
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
$all = array_merge( TMDO_Feature_Flags::HPCT_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
|
||||
$all = array_merge( TMDO_Feature_Flags::ADDON_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
|
||||
echo '<div class="wpdo-details-section">';
|
||||
foreach ( $all as $m ) {
|
||||
printf(
|
||||
|
||||
@@ -246,7 +246,7 @@ class TMDO_Dashboard_Widget {
|
||||
'%2$s'
|
||||
);
|
||||
?>
|
||||
<p class="wpdo-widget-notice <?php echo $total_eav > 0 ? 'wpdo-widget-notice-warn' : 'wpdo-widget-notice-ok'; ?>">
|
||||
<p class="wpdo-widget-notice <?php echo esc_attr( $total_eav > 0 ? 'wpdo-widget-notice-warn' : 'wpdo-widget-notice-ok' ); ?>">
|
||||
📦
|
||||
<?php
|
||||
if ( $total_eav > 0 ) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin notice for the deprecated in-house partner AddOns.
|
||||
*
|
||||
* The AddOns for 2meet's own plugins (bookings, collab, hub, infocards,
|
||||
* mobile-bridge, playlist, quotation, spoke) only registered tables and fields
|
||||
* on the partner's behalf. Partner plugins now register themselves with the
|
||||
* TMDO_Anti_EAV_Aware trait (docs/PARTNER-REGISTRATION.md), so these AddOns
|
||||
* are deprecated and will be removed. The SSO data needs no AddOn at all:
|
||||
* the hub, spoke and SSO AddOns go without replacement (the `sso` group gave
|
||||
* no measurable gain over usermeta) once its values are written back.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns admins who still run one of the deprecated partner AddOns.
|
||||
*/
|
||||
final class TMDO_Deprecated_Addons_Notice {
|
||||
|
||||
/** In-house plugins whose AddOn is replaced by partner self-registration. */
|
||||
private const SLUGS = array( 'bookings', 'collab', 'hub', 'infocards', 'mobile-bridge', 'playlist', 'quotation', 'spoke', 'sso' );
|
||||
|
||||
/**
|
||||
* Hook the notice.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugs of the deprecated AddOns active on this site (or network-wide).
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function active(): array {
|
||||
$active = (array) get_option( 'active_plugins', array() );
|
||||
if ( is_multisite() ) {
|
||||
$active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) );
|
||||
}
|
||||
return array_values(
|
||||
array_filter(
|
||||
self::SLUGS,
|
||||
static fn( string $slug ): bool => in_array( "2meet-data-optimizer-{$slug}-addon/2meet-data-optimizer-{$slug}-addon.php", $active, true )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the notice for administrators when any deprecated AddOn is active.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function render(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
$active = self::active();
|
||||
if ( empty( $active ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$names = implode( ', ', array_map( static fn( string $slug ): string => "2meet-data-optimizer-{$slug}-addon", $active ) );
|
||||
printf(
|
||||
'<div class="notice notice-warning"><p><strong>%s</strong> %s</p><p>%s</p></div>',
|
||||
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
|
||||
esc_html(
|
||||
sprintf(
|
||||
/* translators: %s: comma-separated AddOn plugin slugs */
|
||||
__( '以下 AddOn 已淘汰:%s。對應的 2meet 外掛改為自行向核心註冊資料表與欄位,AddOn 之後會移除。', '2meet-data-optimizer' ),
|
||||
$names
|
||||
)
|
||||
),
|
||||
esc_html__( '改寫方式見 2meet-data-optimizer 外掛內的 docs/PARTNER-REGISTRATION.md;partner 外掛完成自行註冊後即可停用對應 AddOn。', '2meet-data-optimizer' )
|
||||
);
|
||||
if ( array_intersect( $active, array( 'hub', 'spoke', 'sso' ) ) ) {
|
||||
printf(
|
||||
'<div class="notice notice-warning"><p>%s</p></div>',
|
||||
esc_html__( 'Hub / Spoke / SSO AddOn 不需替代:2meet-hub-core 與 2meet-spoke-sso 會自行註冊資料表,SSO 值存回 usermeta 即可。停用前先執行 wp tmdo bridge-restore-native user --group=sso --retire。', '2meet-data-optimizer' )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ class TMDO_Help_Tabs {
|
||||
private static function content_dashboard(): string {
|
||||
return '<p>' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul class="wpdo-list-disc">'
|
||||
. '<li>' . esc_html__( 'System Overview — DB 引擎、HivePress、HPCT、Object Cache 是否啟用', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'System Overview — DB 引擎、HivePress、Object Cache 是否啟用', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'Zone 行數統計 — Hot/Warm/Cold/Archive 各自累積多少資料', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'Module 狀態 — 每個 module 在 7-state FSM 哪一格', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'Warm zone live view — 哪些 view counts / TTL 進來、24h 快過期數', '2meet-data-optimizer' ) . '</li>'
|
||||
@@ -173,7 +173,6 @@ class TMDO_Help_Tabs {
|
||||
. '<p>' . esc_html__( '常見原因:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ul class="wpdo-list-disc">'
|
||||
. '<li>' . esc_html__( 'Hook Bus 啟用(wpdo_hook_bus_enabled = 1)+ legacy interceptors 還沒卸載', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li>' . esc_html__( 'HPCT (HP Custom Tables) plugin 還沒移除 — 與 WPDO 同時攔截', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ul>'
|
||||
. '<p>' . esc_html__( '解法:先看 conflict-scan 詳情,必要時用 wp wpdo bridge-set off 暫停 Hook Bus 直到清理完。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
@@ -184,7 +183,7 @@ class TMDO_Help_Tabs {
|
||||
* @return string
|
||||
*/
|
||||
private static function content_doctor(): string {
|
||||
return '<p>' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
|
||||
return '<p>' . esc_html__( '10 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
|
||||
. '<ol class="wpdo-list-disc">'
|
||||
. '<li><strong>schema_drift</strong> — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>error_budget</strong> — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '</li>'
|
||||
@@ -193,6 +192,9 @@ class TMDO_Help_Tabs {
|
||||
. '<li><strong>postmeta_explosion</strong> — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>orphan_zone_rows</strong> — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>missing_snapshot</strong> — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>entity_group_collisions</strong> — ' . esc_html__( '同名實體群組被不同外掛以不同欄位註冊(後者被忽略)', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>orphaned_entity_groups</strong> — ' . esc_html__( '扁平表還在但已無外掛註冊的群組(已依保存的定義還原以維持可讀)', '2meet-data-optimizer' ) . '</li>'
|
||||
. '<li><strong>missing_integration_addons</strong> — ' . esc_html__( 'HivePress / WooCommerce 已啟用但對應 AddOn 未啟用', '2meet-data-optimizer' ) . '</li>'
|
||||
. '</ol>'
|
||||
. '<p>' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '</p>';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin notice when HivePress / WooCommerce run without their AddOn.
|
||||
*
|
||||
* Core knows nothing about either plugin; their fields are only optimised by
|
||||
* the matching AddOn. Without it, aeav_only data stays readable only through
|
||||
* the orphan-group restore, and new data is not optimised at all.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns administrators about integrations whose AddOn is not active.
|
||||
*/
|
||||
final class TMDO_Missing_Addons_Notice {
|
||||
|
||||
/**
|
||||
* Hook the notice.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the notice for administrators when an integration lacks its AddOn.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function render(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
$missing = TMDO_Compatibility::missing_integration_addons();
|
||||
if ( empty( $missing ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = array();
|
||||
foreach ( $missing as $plugin => $addon ) {
|
||||
$lines[] = sprintf( '%s → %s', $plugin, $addon );
|
||||
}
|
||||
printf(
|
||||
'<div class="notice notice-warning"><p><strong>%s</strong> %s</p></div>',
|
||||
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
|
||||
esc_html(
|
||||
sprintf(
|
||||
/* translators: %s: "Plugin → AddOn" pairs */
|
||||
__( '以下外掛已啟用,但對應的 AddOn 沒有啟用,它們的欄位不會被優化:%s。請啟用對應的 AddOn。', '2meet-data-optimizer' ),
|
||||
implode( ';', $lines )
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin notice when an entity is in aeav_only on a HivePress / WooCommerce site.
|
||||
*
|
||||
* Those plugins read native meta directly, so on an aeav_only entity they read
|
||||
* stale (or, after cleanup, missing) values. The Promotion Guard refuses new
|
||||
* promotions; sites promoted before it existed are only warned — demoting on
|
||||
* read would leave them reading whatever native meta is left.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns administrators about guarded entities that are already in aeav_only.
|
||||
*/
|
||||
final class TMDO_Promotion_Guard_Notice {
|
||||
|
||||
/**
|
||||
* Hook the notice.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the notice for administrators when a guarded entity is in aeav_only.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function render(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
$violations = TMDO_Promotion_Guard::violations();
|
||||
if ( empty( $violations ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = array();
|
||||
foreach ( $violations as $entity_type => $integrations ) {
|
||||
$lines[] = sprintf( '%s(%s):wp tmdo bridge-restore-native %s --demote', $entity_type, implode( ' / ', $integrations ), $entity_type );
|
||||
}
|
||||
printf(
|
||||
'<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>',
|
||||
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
|
||||
esc_html(
|
||||
sprintf(
|
||||
/* translators: %s: "entity (integrations): command" list */
|
||||
__( '以下實體處於 aeav_only,但這些外掛會直接讀取原生 meta,可能讀到過期或遺失的資料。請先備份,再把扁平資料寫回原生並降級:%s', '2meet-data-optimizer' ),
|
||||
implode( ';', $lines )
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ class TMDO_Setup_Wizard {
|
||||
* @return void
|
||||
*/
|
||||
public static function register_menu_page(): void {
|
||||
add_submenu_page(
|
||||
$hook_suffix = add_submenu_page(
|
||||
'tools.php',
|
||||
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
|
||||
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
|
||||
@@ -114,6 +114,33 @@ class TMDO_Setup_Wizard {
|
||||
'wpdo-setup-wizard',
|
||||
array( __CLASS__, 'render_page' )
|
||||
);
|
||||
if ( $hook_suffix ) {
|
||||
add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_snapshot_post' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4 baseline snapshot (POST + nonce, prevents Referer leak — v3.3.x
|
||||
* P1-6b hardening), handled on load-{page} so the redirect runs before any
|
||||
* output.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function handle_snapshot_post(): void {
|
||||
if ( ! isset( $_POST['wpdo_take_snapshot'] ) || ! TMDO_Capability::current_user_can_admin() || ! class_exists( 'TMDO_Snapshot_Manager' ) ) {
|
||||
return;
|
||||
}
|
||||
check_admin_referer( self::NONCE_NAME );
|
||||
$result = TMDO_Snapshot_Manager::create(
|
||||
'manual',
|
||||
array(),
|
||||
array(
|
||||
'notes' => 'wizard_baseline',
|
||||
'retention_days' => 365,
|
||||
)
|
||||
);
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&' . ( empty( $result['ok'] ) ? 'snap_failed=1' : 'snap_done=1' ) ) );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,20 +383,8 @@ class TMDO_Setup_Wizard {
|
||||
</p>
|
||||
</form>
|
||||
<?php
|
||||
// Take snapshot if action requested (POST + nonce, prevents Referer leak — v3.3.x P1-6b hardening).
|
||||
if ( isset( $_POST['wpdo_take_snapshot'] ) && check_admin_referer( self::NONCE_NAME ) && class_exists( 'TMDO_Snapshot_Manager' ) ) {
|
||||
$result = TMDO_Snapshot_Manager::create(
|
||||
'manual',
|
||||
array(),
|
||||
array(
|
||||
'notes' => 'wizard_baseline',
|
||||
'retention_days' => 365,
|
||||
)
|
||||
);
|
||||
if ( ! empty( $result['ok'] ) ) {
|
||||
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&snap_done=1' ) );
|
||||
exit;
|
||||
}
|
||||
// The snapshot itself is taken in handle_snapshot_post(), before any output.
|
||||
if ( isset( $_GET['snap_failed'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>';
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -211,7 +211,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
||||
</div>
|
||||
|
||||
<!-- Progress card (live) -->
|
||||
<div id="wpdo-cst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
|
||||
<div id="wpdo-cst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
|
||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||
<div class="wpdo-bar-section">
|
||||
<div class="wpdo-bar-header">
|
||||
@@ -249,7 +249,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
||||
</div>
|
||||
|
||||
<!-- Benchmark report -->
|
||||
<div id="wpdo-cst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
|
||||
<div id="wpdo-cst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
|
||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||
<div id="wpdo-cst-bench-content"></div>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +99,7 @@ $failed = 'failed' === $state;
|
||||
|
||||
<!-- Run-migration panel (idle state) ──────────────────────────────── -->
|
||||
<section class="wpdo-mw-panel wpdo-mw-run" id="wpdo-mw-run-panel"
|
||||
<?php echo $active || $completed ? 'hidden' : ''; ?>>
|
||||
<?php echo esc_attr( $active || $completed ? 'hidden' : '' ); ?>>
|
||||
|
||||
<?php if ( 0 === $eav_residue && 'aeav_only' === $current_mode ) : ?>
|
||||
<div class="wpdo-mw-nothing-todo">
|
||||
@@ -170,7 +170,7 @@ $failed = 'failed' === $state;
|
||||
|
||||
<!-- Live progress panel (running state) ───────────────────────────── -->
|
||||
<section class="wpdo-mw-panel wpdo-mw-progress" id="wpdo-mw-progress-panel"
|
||||
<?php echo $active || $failed ? '' : 'hidden'; ?>>
|
||||
<?php echo esc_attr( $active || $failed ? '' : 'hidden' ); ?>>
|
||||
|
||||
<h3 id="wpdo-mw-progress-title"><?php esc_html_e( '執行中…', '2meet-data-optimizer' ); ?></h3>
|
||||
|
||||
@@ -210,7 +210,7 @@ $failed = 'failed' === $state;
|
||||
|
||||
<!-- Completed state ───────────────────────────────────────────────── -->
|
||||
<section class="wpdo-mw-panel wpdo-mw-done" id="wpdo-mw-done-panel"
|
||||
<?php echo $completed ? '' : 'hidden'; ?>>
|
||||
<?php echo esc_attr( $completed ? '' : 'hidden' ); ?>>
|
||||
<h3>✅ <?php esc_html_e( '遷移完成', '2meet-data-optimizer' ); ?></h3>
|
||||
<div class="wpdo-mw-done-summary" id="wpdo-mw-done-summary"></div>
|
||||
<button type="button" class="button" id="wpdo-mw-reset"><?php esc_html_e( '回到診斷頁', '2meet-data-optimizer' ); ?></button>
|
||||
|
||||
@@ -218,7 +218,7 @@ $wpdo_format_msg = static function ( string $code ): string {
|
||||
<form method="post">
|
||||
<input type="hidden" name="wpdo_postmeta_cleanup" value="1">
|
||||
<?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?>
|
||||
<button type="submit" class="button button-primary <?php echo (int) $garbage['total'] > 0 ? '' : 'disabled'; ?>"
|
||||
<button type="submit" class="button button-primary <?php echo esc_attr( (int) $garbage['total'] > 0 ? '' : 'disabled' ); ?>"
|
||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要刪除 wp_postmeta 中的垃圾資料?此動作不可逆。', '2meet-data-optimizer' ) ); ?>);">
|
||||
<?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?>
|
||||
</button>
|
||||
|
||||
@@ -161,22 +161,22 @@ $post_type_options = array(
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="<?php echo 'disabled' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>">
|
||||
<tr class="<?php echo esc_attr( 'disabled' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
|
||||
<td><code>disabled</code></td>
|
||||
<td>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td>
|
||||
<td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td>
|
||||
</tr>
|
||||
<tr class="<?php echo 'dual_write' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>">
|
||||
<tr class="<?php echo esc_attr( 'dual_write' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
|
||||
<td><code>dual_write</code></td>
|
||||
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
||||
<td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td>
|
||||
</tr>
|
||||
<tr class="<?php echo 'shadow_read' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>">
|
||||
<tr class="<?php echo esc_attr( 'shadow_read' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
|
||||
<td><code>shadow_read</code></td>
|
||||
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
||||
<td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td>
|
||||
</tr>
|
||||
<tr class="<?php echo 'aeav_only' === $current_post_mode ? 'wpdo-tr-success' : ''; ?>">
|
||||
<tr class="<?php echo esc_attr( 'aeav_only' === $current_post_mode ? 'wpdo-tr-success' : '' ); ?>">
|
||||
<td><code>aeav_only</code></td>
|
||||
<td>wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️</td>
|
||||
<td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td>
|
||||
@@ -291,7 +291,7 @@ $post_type_options = array(
|
||||
</div>
|
||||
|
||||
<!-- Progress section (live) -->
|
||||
<div id="wpdo-pst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
|
||||
<div id="wpdo-pst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
|
||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||
<div class="wpdo-bar-section">
|
||||
<div class="wpdo-bar-header">
|
||||
@@ -329,7 +329,7 @@ $post_type_options = array(
|
||||
</div>
|
||||
|
||||
<!-- Benchmark report -->
|
||||
<div id="wpdo-pst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
|
||||
<div id="wpdo-pst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
|
||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||
<div id="wpdo-pst-bench-content"></div>
|
||||
</div>
|
||||
|
||||
@@ -207,7 +207,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
||||
</div>
|
||||
|
||||
<!-- Progress card (live) -->
|
||||
<div id="wpdo-tst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
|
||||
<div id="wpdo-tst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
|
||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||
<div class="wpdo-bar-section">
|
||||
<div class="wpdo-bar-header">
|
||||
@@ -245,7 +245,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
||||
</div>
|
||||
|
||||
<!-- Benchmark report -->
|
||||
<div id="wpdo-tst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
|
||||
<div id="wpdo-tst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
|
||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||
<div id="wpdo-tst-bench-content"></div>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
* wp wpdo member-shadow-report — Recent entity_type=user diff records.
|
||||
* wp wpdo member-cutover — Switch user mode to aeav_only.
|
||||
* wp wpdo member-points-check — Show balance + recent ledger for a user.
|
||||
* wp wpdo member-sso-sync — Sync Hub membership claims to flat table.
|
||||
* wp wpdo member-force-logout — Set token_expires_at to past → re-auth.
|
||||
* wp wpdo member-sso-sync — (deprecated) Sync Hub membership claims to flat table.
|
||||
* wp wpdo member-force-logout — (deprecated) Set token_expires_at to past → re-auth.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.5.5
|
||||
@@ -35,24 +35,6 @@ if ( ! class_exists( 'WP_CLI' ) ) {
|
||||
*/
|
||||
class TMDO_CLI_Member {
|
||||
|
||||
/**
|
||||
* Valid group names managed by this CLI.
|
||||
*
|
||||
* V2.7.0: Extended with core_profile/social/commerce/hp_user to absorb
|
||||
* the legacy WP/WC/HP wp_usermeta keys.
|
||||
*/
|
||||
private const VALID_GROUPS = array(
|
||||
'membership',
|
||||
'activity',
|
||||
'profile',
|
||||
'sso',
|
||||
'core_profile',
|
||||
'social',
|
||||
'commerce',
|
||||
'hp_user',
|
||||
'admin_prefs',
|
||||
);
|
||||
|
||||
// ── member-audit ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -73,7 +55,7 @@ class TMDO_CLI_Member {
|
||||
WP_CLI::log( '' );
|
||||
|
||||
$items = array();
|
||||
foreach ( self::VALID_GROUPS as $group ) {
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'user' ) as $group ) {
|
||||
$table = $wpdb->prefix . 'wpdo_user_' . $group;
|
||||
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
||||
@@ -111,7 +93,7 @@ class TMDO_CLI_Member {
|
||||
// ── member-backfill ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Backfill wp_usermeta rows into the specified user flat-table group.
|
||||
* Backfill wp_usermeta rows into one user flat-table group, or all of them.
|
||||
*
|
||||
* Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based,
|
||||
* 500 rows/batch, checkpoint stored in wpdo_migration_status.
|
||||
@@ -119,7 +101,10 @@ class TMDO_CLI_Member {
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--group=<group>]
|
||||
* : Which group to backfill: membership, activity, profile, sso. Default: membership.
|
||||
* : Registered user group to backfill (e.g. membership, activity, core_profile). Default: membership.
|
||||
*
|
||||
* [--all]
|
||||
* : Backfill every registered user group (restored orphan groups excluded).
|
||||
*
|
||||
* [--batch-size=<n>]
|
||||
* : Rows per batch (default 500, max 2000).
|
||||
@@ -134,21 +119,38 @@ class TMDO_CLI_Member {
|
||||
*
|
||||
* wp wpdo member-backfill --group=membership --dry-run
|
||||
* wp wpdo member-backfill --group=membership
|
||||
* wp wpdo member-backfill --group=sso --reset
|
||||
* wp wpdo member-backfill --all
|
||||
*
|
||||
* @param array $args Positional arguments.
|
||||
* @param array $assoc_args Named arguments.
|
||||
*/
|
||||
public function member_backfill( $args, $assoc_args ): void {
|
||||
$group = (string) ( $assoc_args['group'] ?? 'membership' );
|
||||
$batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) );
|
||||
$dry_run = isset( $assoc_args['dry-run'] );
|
||||
$reset = isset( $assoc_args['reset'] );
|
||||
$valid = TMDO_Entity_Migration_Engine::backfillable_groups( 'user' );
|
||||
|
||||
if ( ! in_array( $group, self::VALID_GROUPS, true ) ) {
|
||||
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) );
|
||||
if ( isset( $assoc_args['all'] ) ) {
|
||||
if ( isset( $assoc_args['group'] ) ) {
|
||||
WP_CLI::error( 'Use either --group or --all, not both.' );
|
||||
}
|
||||
$groups = $valid;
|
||||
} else {
|
||||
$groups = array( (string) ( $assoc_args['group'] ?? 'membership' ) );
|
||||
if ( ! in_array( $groups[0], $valid, true ) ) {
|
||||
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid ) );
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( $groups as $group ) {
|
||||
$this->backfill_group( $group, $batch_size, $dry_run, $reset );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill one user group and report the result.
|
||||
*/
|
||||
private function backfill_group( string $group, int $batch_size, bool $dry_run, bool $reset ): void {
|
||||
if ( $reset ) {
|
||||
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
|
||||
WP_CLI::log( "Checkpoint cleared for user/{$group}." );
|
||||
@@ -298,8 +300,8 @@ class TMDO_CLI_Member {
|
||||
|
||||
$result = TMDO_Mode_Manager::set( 'user', 'aeav_only' );
|
||||
|
||||
if ( isset( $result['error'] ) ) {
|
||||
WP_CLI::error( 'Cutover failed: ' . $result['error'] );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
WP_CLI::error( 'Cutover failed: ' . $result->get_error_message() );
|
||||
}
|
||||
|
||||
WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' );
|
||||
@@ -356,6 +358,9 @@ class TMDO_CLI_Member {
|
||||
/**
|
||||
* Sync Hub membership claims into the user flat table.
|
||||
*
|
||||
* Deprecated since 1.2.0: the user `sso` group is no longer registered, so
|
||||
* its flat table is never created on new installs. Will be removed.
|
||||
*
|
||||
* Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token
|
||||
* from usermeta and writes them into the sso group flat table.
|
||||
* Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing
|
||||
@@ -381,6 +386,7 @@ class TMDO_CLI_Member {
|
||||
* @param array $assoc_args Named arguments.
|
||||
*/
|
||||
public function member_sso_sync( $args, $assoc_args ): void {
|
||||
WP_CLI::warning( 'member-sso-sync is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' );
|
||||
global $wpdb;
|
||||
|
||||
$user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0;
|
||||
@@ -466,6 +472,9 @@ class TMDO_CLI_Member {
|
||||
/**
|
||||
* Force re-authentication by setting token_expires_at to a past datetime.
|
||||
*
|
||||
* Deprecated since 1.2.0: works on the user `sso` flat table, which is no
|
||||
* longer registered. Will be removed.
|
||||
*
|
||||
* The Spoke silent-refresh logic detects an expired token_expires_at and
|
||||
* redirects the user to re-authenticate against the Hub. If the Hub has
|
||||
* the account blocked, the user is logged out from all Spokes.
|
||||
@@ -490,6 +499,7 @@ class TMDO_CLI_Member {
|
||||
* @param array $assoc_args Named arguments.
|
||||
*/
|
||||
public function member_force_logout( $args, $assoc_args ): void {
|
||||
WP_CLI::warning( 'member-force-logout is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' );
|
||||
if ( ! isset( $assoc_args['confirm'] ) ) {
|
||||
WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' );
|
||||
}
|
||||
|
||||
+147
-32
@@ -198,29 +198,20 @@ class TMDO_CLI_Post {
|
||||
|
||||
/**
|
||||
* Backfill one post entity group from wp_postmeta into its flat table
|
||||
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE).
|
||||
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE). Groups with
|
||||
* JSON-typed fields are backfilled row by row instead.
|
||||
*
|
||||
* Idempotent — safe to re-run. Skips JSON-typed fields (handled by the
|
||||
* row-by-row backfill phase in v2.9.4+).
|
||||
* Idempotent — safe to re-run.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* --group=<group>
|
||||
* : Entity group name. Required.
|
||||
* ---
|
||||
* options:
|
||||
* - wp_core
|
||||
* - attachment
|
||||
* - wc_product
|
||||
* - hp_listing_core
|
||||
* - hp_request_core
|
||||
* - hp_vendor_core
|
||||
* - nav_menu_item
|
||||
* ---
|
||||
* : Registered post entity group name (e.g. wp_core, attachment,
|
||||
* hp_listing_core). Required.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp wpdo post-migrate-group --group=wc_product
|
||||
* wp wpdo post-migrate-group --group=hp_listing_core
|
||||
* wp wpdo post-migrate-group --group=hp_listing_core
|
||||
*
|
||||
* @param array $args Positional arguments (unused).
|
||||
@@ -259,6 +250,94 @@ class TMDO_CLI_Post {
|
||||
);
|
||||
}
|
||||
|
||||
// ── post-reconcile-groups ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Move post values written under legacy routing into their post type's
|
||||
* group table, then switch on post_type-scoped routing.
|
||||
*
|
||||
* Needed once on sites that already hold post flat rows (the installer
|
||||
* leaves tmdo_post_scoped_routing off there). Resumable: an interrupted
|
||||
* run continues from its cursor.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--dry-run]
|
||||
* : Count the values that would be copied / overwritten; write nothing.
|
||||
*
|
||||
* [--batch=<n>]
|
||||
* : Rows per batch. Default 500.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp tmdo post-reconcile-groups --dry-run
|
||||
* wp tmdo post-reconcile-groups
|
||||
*
|
||||
* @param array<int, string> $args Positional arguments (unused).
|
||||
* @param array<string, string> $assoc_args Named arguments.
|
||||
*/
|
||||
public function post_reconcile_groups( $args, $assoc_args ): void {
|
||||
$dry_run = isset( $assoc_args['dry-run'] );
|
||||
$report = TMDO_Post_Group_Reconciler::run( $dry_run, (int) ( $assoc_args['batch'] ?? 500 ) );
|
||||
|
||||
if ( empty( $report['moves'] ) ) {
|
||||
WP_CLI::log( 'No shared post keys to move.' );
|
||||
}
|
||||
foreach ( $report['moves'] as $m ) {
|
||||
WP_CLI::log( sprintf( '%s → %s %s: %d value(s)%s', $m['source'], $m['target'], $m['key'], $m['copied'], $dry_run ? sprintf( ', %d would overwrite a different value', $m['overwritten'] ) : '' ) );
|
||||
}
|
||||
|
||||
if ( $dry_run ) {
|
||||
WP_CLI::success( '[dry-run] Nothing written. Re-run without --dry-run to copy and switch on scoped routing.' );
|
||||
return;
|
||||
}
|
||||
WP_CLI::success( 'Post groups reconciled; tmdo_post_scoped_routing is on.' );
|
||||
}
|
||||
|
||||
// ── post-status-sync ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add post_status / post_date copies (and their index) to every post
|
||||
* group table and fill them from wp_posts.
|
||||
*
|
||||
* Existing tables only get the columns here, never in a web request:
|
||||
* ALTER on a large table can take seconds. Afterwards status changes keep
|
||||
* the copies current. Re-run after bulk writes that bypass WordPress
|
||||
* (e.g. post-reconcile-groups, post migrations).
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--dry-run]
|
||||
* : Count rows whose copies are missing or stale; change nothing.
|
||||
*
|
||||
* [--batch=<n>]
|
||||
* : Rows per UPDATE. Default 1000.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp tmdo post-status-sync --dry-run
|
||||
* wp tmdo post-status-sync
|
||||
*
|
||||
* @param array<int, string> $args Positional arguments (unused).
|
||||
* @param array<string, string> $assoc_args Named arguments.
|
||||
*/
|
||||
public function post_status_sync( $args, $assoc_args ): void {
|
||||
$dry_run = isset( $assoc_args['dry-run'] );
|
||||
$report = TMDO_Post_Status_Sync::run( $dry_run, (int) ( $assoc_args['batch'] ?? 1000 ) );
|
||||
|
||||
if ( empty( $report ) ) {
|
||||
WP_CLI::log( 'No post group tables.' );
|
||||
}
|
||||
foreach ( $report as $group => $r ) {
|
||||
WP_CLI::log( sprintf( '%s: %d row(s), %d missing or stale%s', $group, $r['rows'], $r['mismatched'], $dry_run ? '' : sprintf( ' → %s', TMDO_Post_Status_Sync::state( $group ) ) ) );
|
||||
}
|
||||
if ( $dry_run ) {
|
||||
WP_CLI::success( '[dry-run] Nothing changed.' );
|
||||
return;
|
||||
}
|
||||
WP_CLI::success( 'post_status / post_date copies are in sync.' );
|
||||
}
|
||||
|
||||
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -351,6 +430,10 @@ class TMDO_CLI_Post {
|
||||
* Safe to run with the filter enabled; the filter prevents new postmeta
|
||||
* writes from re-bloating the table.
|
||||
*
|
||||
* With --options it removes instead the rows the filter wrote to wp_options
|
||||
* (`_transient_wpdo_hp_pm_*`) before 1.2.0 / AddOn 0.3.0 kept them in the
|
||||
* object cache; works without the AddOn active.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--dry-run]
|
||||
@@ -359,10 +442,17 @@ class TMDO_CLI_Post {
|
||||
* [--confirm]
|
||||
* : Required to actually DELETE rows.
|
||||
*
|
||||
* [--options]
|
||||
* : Clean the AddOn's rows in wp_options instead of wp_postmeta.
|
||||
*
|
||||
* [--batch=<n>]
|
||||
* : Rows per DELETE with --options. Default 5000.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp wpdo cleanup-hp-transients --dry-run
|
||||
* wp wpdo cleanup-hp-transients --confirm
|
||||
* wp wpdo cleanup-hp-transients --options --dry-run
|
||||
*
|
||||
* @param array $args Positional arguments (unused).
|
||||
* @param array $assoc_args Named arguments.
|
||||
@@ -382,6 +472,11 @@ class TMDO_CLI_Post {
|
||||
);
|
||||
}
|
||||
|
||||
if ( isset( $assoc_args['options'] ) ) {
|
||||
self::cleanup_hp_transient_options( $dry_run, max( 1, (int) ( $assoc_args['batch'] ?? 5000 ) ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
|
||||
WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' );
|
||||
}
|
||||
@@ -415,6 +510,39 @@ class TMDO_CLI_Post {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count or delete the HivePress AddOn's transient rows in wp_options, in
|
||||
* batches (dev36 held 1.15 M of them).
|
||||
*
|
||||
* @param bool $dry_run Count only.
|
||||
* @param int $batch Rows per DELETE.
|
||||
* @return void
|
||||
*/
|
||||
private static function cleanup_hp_transient_options( bool $dry_run, int $batch ): void {
|
||||
global $wpdb;
|
||||
// TMDO_Hivepress_Transient_Filter::TRANSLATED_NAMESPACE (AddOn), repeated
|
||||
// here so the leftovers can be removed with the AddOn gone.
|
||||
$ns = $wpdb->esc_like( 'wpdo_hp_pm_' );
|
||||
$where = $wpdb->prepare( 'option_name LIKE %s OR option_name LIKE %s', '\_transient\_' . $ns . '%', '\_transient\_timeout\_' . $ns . '%' );
|
||||
|
||||
if ( $dry_run ) {
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->options} WHERE {$where}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
WP_CLI::success( sprintf( '[dry-run] %d HivePress transient row(s) in wp_options would be deleted. Re-run with --confirm.', $count ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
do {
|
||||
$rows = (int) $wpdb->query( "DELETE FROM {$wpdb->options} WHERE {$where} LIMIT {$batch}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$deleted += $rows;
|
||||
} while ( $rows === $batch );
|
||||
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::info( 'cleanup_hp_transient_options', array( 'deleted' => $deleted ) );
|
||||
}
|
||||
WP_CLI::success( sprintf( 'Deleted %d HivePress transient row(s) from wp_options.', $deleted ) );
|
||||
}
|
||||
|
||||
// ── post-cutover-legacy (v2.9.5) ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -816,23 +944,7 @@ class TMDO_CLI_Post {
|
||||
* @return string|null
|
||||
*/
|
||||
private static function group_post_type_for_cli( string $group ): ?string {
|
||||
switch ( $group ) {
|
||||
case 'attachment':
|
||||
return 'attachment';
|
||||
case 'wc_product':
|
||||
return 'product';
|
||||
case 'hp_listing_core':
|
||||
return 'hp_listing';
|
||||
case 'hp_request_core':
|
||||
return 'hp_request';
|
||||
case 'hp_vendor_core':
|
||||
return 'hp_vendor';
|
||||
case 'nav_menu_item':
|
||||
return 'nav_menu_item';
|
||||
case 'wp_core':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return TMDO_Post_Fields::post_type_for_group( $group );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -853,3 +965,6 @@ WP_CLI::add_command( 'wpdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchm
|
||||
WP_CLI::add_command( 'tmdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
|
||||
WP_CLI::add_command( '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( new TMDO_CLI_Post(), 'post_reconcile_groups' ) );
|
||||
WP_CLI::add_command( 'tmdo post-reconcile-groups', array( new TMDO_CLI_Post(), 'post_reconcile_groups' ) );
|
||||
WP_CLI::add_command( 'tmdo post-status-sync', array( new TMDO_CLI_Post(), 'post_status_sync' ) );
|
||||
|
||||
@@ -429,7 +429,7 @@ class TMDO_CLI_Term_Comment {
|
||||
WP_CLI::log( sprintf( 'Mode: %s', $term_mode ) );
|
||||
WP_CLI::log( sprintf( 'wp_terms: %s', number_format_i18n( $terms_count ) ) );
|
||||
WP_CLI::log( sprintf( 'wp_termmeta: %s (1:%s)', number_format_i18n( $termmeta_count ), $terms_count > 0 ? round( $termmeta_count / $terms_count, 2 ) : 'n/a' ) );
|
||||
self::print_flat_row( $wpdb->prefix . 'wpdo_term_hp_taxonomy', 'wpdo_term_hp_taxonomy' );
|
||||
self::print_group_rows( 'term' );
|
||||
self::print_flat_row( $wpdb->prefix . 'wpdo_term_misc', 'wpdo_term_misc' );
|
||||
|
||||
WP_CLI::log( '' );
|
||||
@@ -437,14 +437,14 @@ class TMDO_CLI_Term_Comment {
|
||||
WP_CLI::log( sprintf( 'Mode: %s', $comment_mode ) );
|
||||
WP_CLI::log( sprintf( 'wp_comments: %s', number_format_i18n( $comments_count ) ) );
|
||||
WP_CLI::log( sprintf( 'wp_commentmeta: %s (1:%s)', number_format_i18n( $commentmeta_count ), $comments_count > 0 ? round( $commentmeta_count / $comments_count, 2 ) : 'n/a' ) );
|
||||
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_hp_review', 'wpdo_comment_hp_review' );
|
||||
self::print_group_rows( 'comment' );
|
||||
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_misc', 'wpdo_comment_misc' );
|
||||
|
||||
WP_CLI::log( '' );
|
||||
WP_CLI::log( '── Filter chain status ──' );
|
||||
self::print_filter_status( 'wpdo_term_comment_garbage_filter_enabled', 'Garbage filter (Phase 1)' );
|
||||
self::print_filter_status( 'wpdo_wc_term_count_filter_enabled', 'WC term count filter (Phase 3)' );
|
||||
self::print_filter_status( 'wpdo_term_comment_misc_bucket_enabled', 'Misc bucket (Phase 4)' );
|
||||
self::print_filter_status( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter (Phase 1)' );
|
||||
self::print_filter_status( 'TMDO_WC_Term_Count_Filter', 'WC term count filter (Phase 3)' );
|
||||
self::print_filter_status( 'TMDO_Term_Comment_Misc_Bucket', 'Misc bucket (Phase 4)' );
|
||||
|
||||
WP_CLI::log( '' );
|
||||
WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) );
|
||||
@@ -470,6 +470,21 @@ class TMDO_CLI_Term_Comment {
|
||||
return 'Both entities at disabled. Promote to dual_write first to enable Hook Bus interception.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the row count of every registered group table of an entity.
|
||||
*
|
||||
* @param string $entity_type 'term' or 'comment'.
|
||||
* @return void
|
||||
*/
|
||||
private static function print_group_rows( string $entity_type ): void {
|
||||
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $type ) {
|
||||
if ( $type === $entity_type ) {
|
||||
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||
self::print_flat_row( $table, TMDO_TABLE_PREFIX . $entity_type . '_' . $group );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a flat table's row count (or "(missing)" when not present).
|
||||
*
|
||||
@@ -489,15 +504,19 @@ class TMDO_CLI_Term_Comment {
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a filter's enabled/disabled status.
|
||||
* Print a filter's enabled/disabled status, as the filter itself decides it
|
||||
* (each has its own default).
|
||||
*
|
||||
* @param string $option_key Option storing the toggle value.
|
||||
* @param string $label Human-readable name.
|
||||
* @param string $filter_class Filter class with a static is_enabled().
|
||||
* @param string $label Human-readable name.
|
||||
* @return void
|
||||
*/
|
||||
private static function print_filter_status( string $option_key, string $label ): void {
|
||||
$enabled = (bool) get_option( $option_key, '1' );
|
||||
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $enabled ? '✓ enabled' : '✗ disabled' ) );
|
||||
private static function print_filter_status( string $filter_class, string $label ): void {
|
||||
if ( ! class_exists( $filter_class ) ) {
|
||||
WP_CLI::log( sprintf( '%-32s%s', $label . ':', '— not installed' ) );
|
||||
return;
|
||||
}
|
||||
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $filter_class::is_enabled() ? '✓ enabled' : '✗ disabled' ) );
|
||||
}
|
||||
|
||||
// ── term-comment-shadow-report (v2.12.5 Phase 5) ──────────────────────────
|
||||
@@ -647,7 +666,8 @@ class TMDO_CLI_Term_Comment {
|
||||
);
|
||||
}
|
||||
|
||||
$valid_groups = array( 'all', 'hp_taxonomy', 'hp_review' );
|
||||
$registered = TMDO_Term_Comment_Backfill::registered_groups();
|
||||
$valid_groups = array_merge( array( 'all' ), array_keys( $registered ) );
|
||||
if ( ! in_array( $group, $valid_groups, true ) ) {
|
||||
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid_groups ) );
|
||||
}
|
||||
@@ -656,8 +676,7 @@ class TMDO_CLI_Term_Comment {
|
||||
if ( 'all' === $group ) {
|
||||
$results = TMDO_Term_Comment_Backfill::backfill_all( $dry_run );
|
||||
} else {
|
||||
$entity_type = 'hp_review' === $group ? 'comment' : 'term';
|
||||
$results = array( $group => TMDO_Term_Comment_Backfill::backfill_group( $entity_type, $group, $dry_run ) );
|
||||
$results = array( $group => TMDO_Term_Comment_Backfill::backfill_group( $registered[ $group ], $group, $dry_run ) );
|
||||
}
|
||||
|
||||
// Render table.
|
||||
|
||||
+280
-6
@@ -91,22 +91,29 @@ class TMDO_CLI_V2 {
|
||||
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table )
|
||||
);
|
||||
|
||||
$flat_count = $flat_exists
|
||||
// Flat rows holding at least one value (wp_core keeps an all-NULL row per post).
|
||||
$managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
|
||||
$has_value = implode( ' OR ', array_map( static fn( $k ) => '`' . TMDO_Schema_Manager::sanitize_column_name( $k ) . '` IS NOT NULL', $managed_keys ) );
|
||||
$flat_count = $flat_exists && '' !== $has_value
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" )
|
||||
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}` WHERE {$has_value}" )
|
||||
: 0;
|
||||
|
||||
// EAV source: count distinct entity IDs that have at least one managed key.
|
||||
$managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
|
||||
$eav_table = $eav_tables[ $entity_type ] ?? '';
|
||||
$eav_count = 0;
|
||||
$eav_table = $eav_tables[ $entity_type ] ?? '';
|
||||
$eav_count = 0;
|
||||
|
||||
if ( '' !== $eav_table && ! empty( $managed_keys ) ) {
|
||||
$id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id";
|
||||
$placeholders = implode( ', ', array_fill( 0, count( $managed_keys ), '%s' ) );
|
||||
// $id_col comes from a hardcoded map; $eav_table is $wpdb->*meta (framework-managed); $placeholders is %s repeats only.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
$sql = "SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$eav_table}` WHERE `meta_key` IN ({$placeholders})";
|
||||
$sql = "SELECT COUNT(DISTINCT m.`{$id_col}`) FROM `{$eav_table}` m WHERE m.`meta_key` IN ({$placeholders})";
|
||||
// A group of some post types only: other posts holding its keys are not its rows.
|
||||
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( 'post', $group_name ) : array();
|
||||
if ( $post_types ) {
|
||||
$sql .= " AND m.post_id IN ( SELECT ID FROM {$wpdb->posts} WHERE post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "') )";
|
||||
}
|
||||
$eav_count = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$managed_keys ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
}
|
||||
|
||||
@@ -152,6 +159,265 @@ class TMDO_CLI_V2 {
|
||||
WP_CLI::success( "Hook Bus: {$state}" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an entity's bridge mode. Goes through TMDO_Mode_Manager::set(): unsafe
|
||||
* jumps and promotions refused by the Promotion Guard (aeav_only on
|
||||
* HivePress / WooCommerce sites) fail and leave the mode unchanged.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* <entity>
|
||||
* : post | user | term | comment
|
||||
*
|
||||
* <mode>
|
||||
* : disabled | dual_write | shadow_read | aeav_only
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp tmdo bridge-mode user dual_write
|
||||
* wp tmdo bridge-mode post disabled
|
||||
*
|
||||
* @param array<int, string> $args Entity type and mode.
|
||||
* @param array<string, string> $assoc_args Unused.
|
||||
*/
|
||||
public function bridge_mode( $args, $assoc_args ): void {
|
||||
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
|
||||
$entity = (string) ( $args[0] ?? '' );
|
||||
$mode = (string) ( $args[1] ?? '' );
|
||||
$from = TMDO_Mode_Manager::get( $entity );
|
||||
|
||||
$result = TMDO_Mode_Manager::set( $entity, $mode );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
WP_CLI::error( $result->get_error_message() );
|
||||
}
|
||||
WP_CLI::success( "{$entity}: {$from} → {$mode}" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare an entity's flat tables with native meta, optionally repairing them.
|
||||
*
|
||||
* Reports per key how many values are missing from flat (miss), differ
|
||||
* (diff) or exist only in flat (extra), and records the outcome per group
|
||||
* in the wpdo_drift_status option. Read-only unless --fix is given.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* <entity>
|
||||
* : post | user | term | comment
|
||||
*
|
||||
* [--group=<group>]
|
||||
* : Only this group. Default: every backfillable group.
|
||||
*
|
||||
* [--sample=<n>]
|
||||
* : Check n random entities per group instead of all of them.
|
||||
*
|
||||
* [--fix]
|
||||
* : Rebuild drifted flat rows from native meta (native is never written),
|
||||
* delete rows of deleted entities / other post types, then check again.
|
||||
* Not combinable with --sample.
|
||||
*
|
||||
* [--format=<format>]
|
||||
* : table or json. Default: table.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp tmdo drift-check post
|
||||
* wp tmdo drift-check user --group=activity
|
||||
* wp tmdo drift-check post --sample=500
|
||||
* wp tmdo drift-check post --fix
|
||||
*
|
||||
* @param array<int, string> $args Entity type.
|
||||
* @param array<string, string> $assoc_args Options.
|
||||
*/
|
||||
public function drift_check( $args, $assoc_args ): void {
|
||||
$entity = (string) ( $args[0] ?? '' );
|
||||
$group = (string) ( $assoc_args['group'] ?? '' );
|
||||
$sample = max( 0, (int) ( $assoc_args['sample'] ?? 0 ) );
|
||||
$fix = isset( $assoc_args['fix'] );
|
||||
if ( $fix && $sample ) {
|
||||
WP_CLI::error( '--fix repairs every entity; it cannot be combined with --sample.' );
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ( $fix ) {
|
||||
$result = TMDO_Drift_Check::fix( $entity, $group );
|
||||
$report = $result['after'];
|
||||
WP_CLI::log( sprintf( 'Fixed: %d row(s) rebuilt from native, %d cleared, %d stray row(s) deleted.', $result['fixed']['rebuilt'], $result['fixed']['cleared'], $result['fixed']['deleted_rows'] ) );
|
||||
} else {
|
||||
$report = TMDO_Drift_Check::run( $entity, $group, $sample );
|
||||
}
|
||||
} catch ( InvalidArgumentException | RuntimeException $e ) {
|
||||
WP_CLI::error( $e->getMessage() );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'json' === ( $assoc_args['format'] ?? 'table' ) ) {
|
||||
WP_CLI::line( (string) wp_json_encode( $report ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = array();
|
||||
foreach ( $report['groups'] as $group => $result ) {
|
||||
foreach ( $result['keys'] as $key => $counts ) {
|
||||
if ( $counts['miss'] + $counts['diff'] + $counts['extra'] > 0 ) {
|
||||
$rows[] = array_merge(
|
||||
array(
|
||||
'group' => $group,
|
||||
'key' => $key,
|
||||
),
|
||||
$counts
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( $rows ) {
|
||||
\WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'key', 'miss', 'diff', 'extra' ) );
|
||||
}
|
||||
|
||||
$total = $report['total'];
|
||||
$summary = sprintf( '%s: %d group(s)%s — miss=%d diff=%d extra=%d', $entity, count( $report['groups'] ), $sample ? " (sample {$sample})" : '', $total['miss'], $total['diff'], $total['extra'] );
|
||||
if ( $total['miss'] + $total['diff'] + $total['extra'] > 0 ) {
|
||||
WP_CLI::warning( $summary );
|
||||
return;
|
||||
}
|
||||
WP_CLI::success( $summary );
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an entity's flat-table values back to native meta — the way out of
|
||||
* aeav_only. Resumable; re-run after an interruption.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* <entity>
|
||||
* : post | user | term | comment
|
||||
*
|
||||
* [--dry-run]
|
||||
* : Count the flat rows per group and how their values compare with native
|
||||
* meta (missing / differs / same / default skipped); write nothing.
|
||||
*
|
||||
* [--overwrite]
|
||||
* : Flat values replace differing native values. Default in aeav_only.
|
||||
*
|
||||
* [--only-missing]
|
||||
* : Only fill native rows that do not exist; never replace. Default in every
|
||||
* other mode, where native meta is the source of truth.
|
||||
*
|
||||
* [--batch=<n>]
|
||||
* : Rows per batch. Default 500.
|
||||
*
|
||||
* [--demote]
|
||||
* : After a complete run, switch the entity to dual_write (both copies kept in step).
|
||||
*
|
||||
* [--group=<group>]
|
||||
* : Only this group.
|
||||
*
|
||||
* [--retire]
|
||||
* : With --group, after the write-back: forget the group (registry_meta row)
|
||||
* and rename its flat table to <table>_retired_<Ymd>. Refused while a plugin
|
||||
* still registers the group or native meta differs from the flat table.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp tmdo bridge-restore-native user --dry-run
|
||||
* wp tmdo bridge-restore-native user --demote
|
||||
* wp tmdo bridge-restore-native user --group=sso --retire
|
||||
*
|
||||
* @param array<int, string> $args Positional arguments.
|
||||
* @param array<string, string> $assoc_args Named arguments.
|
||||
*/
|
||||
public function bridge_restore_native( $args, $assoc_args ): void {
|
||||
$entity = (string) ( $args[0] ?? '' );
|
||||
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
||||
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--overwrite|--only-missing] [--batch=<n>] [--demote] [--group=<group> [--retire]]' );
|
||||
}
|
||||
$dry_run = isset( $assoc_args['dry-run'] );
|
||||
$group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) );
|
||||
if ( isset( $assoc_args['retire'] ) && '' === $group ) {
|
||||
WP_CLI::error( '--retire needs --group=<group>.' );
|
||||
}
|
||||
if ( isset( $assoc_args['overwrite'] ) && isset( $assoc_args['only-missing'] ) ) {
|
||||
WP_CLI::error( 'Use either --overwrite or --only-missing, not both.' );
|
||||
}
|
||||
$policy = isset( $assoc_args['overwrite'] ) ? 'overwrite' : ( isset( $assoc_args['only-missing'] ) ? 'only_missing' : '' );
|
||||
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group, $policy );
|
||||
|
||||
WP_CLI::log( "Policy: {$report['policy']}" );
|
||||
foreach ( $report['groups'] as $group => $g ) {
|
||||
WP_CLI::log(
|
||||
sprintf(
|
||||
'%s/%s: %d row(s) — native missing %d, differs %d, same %d, default skipped %d%s',
|
||||
$entity,
|
||||
$group,
|
||||
$g['rows'],
|
||||
$g['missing'],
|
||||
$g['differs'],
|
||||
$g['same'],
|
||||
$g['default_skipped'],
|
||||
$dry_run ? '' : sprintf( '; %d native value(s) written', $g['written'] )
|
||||
)
|
||||
);
|
||||
}
|
||||
if ( $dry_run ) {
|
||||
WP_CLI::success( '[dry-run] Nothing written.' );
|
||||
return;
|
||||
}
|
||||
if ( isset( $assoc_args['retire'] ) ) {
|
||||
$retired = TMDO_Native_Restorer::retire( $entity, $group );
|
||||
if ( 'retired' !== $retired['status'] ) {
|
||||
WP_CLI::error( sprintf( 'Not retired (%s%s). Written values stay in native meta.', $retired['status'], $retired['mismatches'] ? ", {$retired['mismatches']} value(s) differ" : '' ) );
|
||||
}
|
||||
WP_CLI::success( "{$entity}/{$group} retired; flat table kept as {$retired['archived_table']}." );
|
||||
return;
|
||||
}
|
||||
WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Find flat text / textarea values stored as the literal "Array" (pre-1.1.0
|
||||
* type caster) and rewrite them from native meta.
|
||||
*
|
||||
* Native rows that are "Array" too cannot be repaired here; they are listed
|
||||
* so they can be restored from a backup.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--entity=<entity>]
|
||||
* : post | user | term | comment. Default: all four.
|
||||
*
|
||||
* [--confirm]
|
||||
* : Write the repairs. Without it the command only reports.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp tmdo repair-array-literals
|
||||
* wp tmdo repair-array-literals --entity=user --confirm
|
||||
*
|
||||
* @param array<int, string> $args Positional arguments (unused).
|
||||
* @param array<string, string> $assoc_args Named arguments.
|
||||
*/
|
||||
public function repair_array_literals( $args, $assoc_args ): void {
|
||||
$entities = isset( $assoc_args['entity'] ) ? array( (string) $assoc_args['entity'] ) : TMDO_Entity_Registry::VALID_ENTITY_TYPES;
|
||||
$apply = isset( $assoc_args['confirm'] );
|
||||
|
||||
foreach ( $entities as $entity ) {
|
||||
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
||||
WP_CLI::error( "Unknown entity: {$entity}" );
|
||||
}
|
||||
$report = TMDO_Array_Literal_Repair::run( $entity, $apply );
|
||||
WP_CLI::log( sprintf( '%s: %d "Array" value(s) in flat tables, %d repairable from native, %d repaired.', $entity, $report['found'], $report['repairable'], $report['repaired'] ) );
|
||||
foreach ( $report['unrepairable'] as $row ) {
|
||||
WP_CLI::log( sprintf( ' unrepairable %s/%s #%d (%s)', $row['group'], $row['key'], $row['id'], $row['reason'] ) );
|
||||
}
|
||||
foreach ( $report['native_polluted'] as $row ) {
|
||||
WP_CLI::warning( sprintf( 'native meta is "Array": %s %s #%d — restore it from a backup', $entity, $row['key'], $row['id'] ) );
|
||||
}
|
||||
}
|
||||
|
||||
WP_CLI::success( $apply ? 'Repair finished.' : '[dry-run] Nothing written. Re-run with --confirm to repair.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit per-module state + shadow_read flags.
|
||||
*
|
||||
@@ -784,6 +1050,14 @@ WP_CLI::add_command( 'wpdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status'
|
||||
WP_CLI::add_command( 'tmdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) );
|
||||
WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
|
||||
WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
|
||||
WP_CLI::add_command( 'wpdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
|
||||
WP_CLI::add_command( 'tmdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
|
||||
WP_CLI::add_command( 'wpdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
|
||||
WP_CLI::add_command( 'tmdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
|
||||
WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
|
||||
WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
|
||||
WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
|
||||
WP_CLI::add_command( 'tmdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
|
||||
WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
|
||||
WP_CLI::add_command( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
|
||||
WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) );
|
||||
|
||||
+129
-89
@@ -42,7 +42,6 @@ class TMDO_CLI {
|
||||
|
||||
$compat = TMDO_Compatibility::check();
|
||||
WP_CLI::log( 'HivePress: ' . ( $compat['hivepress'] ? 'Active' : 'Not found' ) );
|
||||
WP_CLI::log( 'HP Custom Tables: ' . self::format_hpct_status( $compat ) );
|
||||
WP_CLI::log( 'Object Cache: ' . ( wp_using_ext_object_cache() ? 'External' : 'Built-in' ) );
|
||||
WP_CLI::log( '' );
|
||||
|
||||
@@ -57,10 +56,10 @@ class TMDO_CLI {
|
||||
|
||||
WP_CLI::log( 'Module States:' );
|
||||
|
||||
$hpct_flags = TMDO_Feature_Flags::hpct_modules();
|
||||
if ( ! empty( $hpct_flags ) ) {
|
||||
WP_CLI::log( ' [HPCT Modules]' );
|
||||
foreach ( $hpct_flags as $module => $state ) {
|
||||
$addon_flags = TMDO_Feature_Flags::addon_modules();
|
||||
if ( ! empty( $addon_flags ) ) {
|
||||
WP_CLI::log( ' [AddOn Modules]' );
|
||||
foreach ( $addon_flags as $module => $state ) {
|
||||
WP_CLI::log( " {$module}: {$state}" );
|
||||
}
|
||||
}
|
||||
@@ -95,6 +94,92 @@ class TMDO_CLI {
|
||||
WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the flat table of every backfillable group of every entity type,
|
||||
* plus the installer's migration status and points ledger tables.
|
||||
*
|
||||
* @return bool Every table exists.
|
||||
*/
|
||||
public static function check_entity_tables(): bool {
|
||||
global $wpdb;
|
||||
|
||||
$tables = array( $wpdb->prefix . 'wpdo_migration_status', $wpdb->prefix . 'wpdo_user_points_ledger' );
|
||||
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||
foreach ( TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type ) as $group ) {
|
||||
$tables[] = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||
}
|
||||
}
|
||||
|
||||
$ok = true;
|
||||
foreach ( $tables as $t ) {
|
||||
if ( TMDO_IS_SQLITE ) {
|
||||
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
|
||||
} else {
|
||||
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
|
||||
}
|
||||
if ( $exists ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
|
||||
WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
|
||||
} else {
|
||||
WP_CLI::warning( " [MISSING] {$t} — run `wp wpdo install` to create." );
|
||||
$ok = false;
|
||||
}
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report Promotion Guard violations and the recorded drift status of every
|
||||
* entity that writes flat tables or has been checked (informational).
|
||||
*/
|
||||
public static function report_guard_and_drift(): void {
|
||||
WP_CLI::log( '' );
|
||||
$violations = TMDO_Promotion_Guard::violations();
|
||||
if ( empty( $violations ) ) {
|
||||
WP_CLI::log( ' [OK] Promotion Guard: no violation' );
|
||||
}
|
||||
foreach ( $violations as $entity_type => $integrations ) {
|
||||
WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." );
|
||||
}
|
||||
$deferred = array_keys( (array) get_option( TMDO_Schema_Manager::DEFERRED_OPTION, array() ) );
|
||||
if ( $deferred ) {
|
||||
WP_CLI::warning( ' [SCHEMA] groups using native meta until their table upgrade and the drift resync after it (tmdo_drift_resync) are done: ' . implode( ', ', $deferred ) . '.' );
|
||||
}
|
||||
$pending = TMDO_Schema_Manager::pending_indexes();
|
||||
if ( $pending ) {
|
||||
WP_CLI::warning( ' [INDEX] declared indexes not added yet: ' . implode( ', ', $pending ) . ' — still missing after this run: another process is adding them, or the ALTER failed (see the `schema.index_failed` log).' );
|
||||
}
|
||||
|
||||
$status = get_option( TMDO_Drift_Check::OPTION, array() );
|
||||
$status = is_array( $status ) ? $status : array();
|
||||
WP_CLI::log( 'Drift (last `wp tmdo drift-check` or daily sample):' );
|
||||
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
|
||||
// Records of groups no longer registered can be neither re-checked nor fixed.
|
||||
$last = array_intersect_key( (array) ( $status[ $entity_type ] ?? array() ), array_flip( $groups ) );
|
||||
if ( empty( $groups ) || ( empty( $last ) && ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( empty( $last ) ) {
|
||||
WP_CLI::log( " {$entity_type}: never checked — run `wp tmdo drift-check {$entity_type}`" );
|
||||
continue;
|
||||
}
|
||||
$drifting = array();
|
||||
foreach ( $last as $group => $s ) {
|
||||
if ( (int) $s['miss'] + (int) $s['diff'] + (int) $s['extra'] > 0 ) {
|
||||
$drifting[] = sprintf( '%s miss=%d diff=%d extra=%d', $group, $s['miss'], $s['diff'], $s['extra'] );
|
||||
}
|
||||
}
|
||||
$oldest = min( array_column( $last, 'checked_at' ) );
|
||||
if ( $drifting ) {
|
||||
WP_CLI::warning( " {$entity_type}: " . implode( '; ', $drifting ) . " (checked {$oldest} UTC or later) — `wp tmdo drift-check {$entity_type} --fix`" );
|
||||
} else {
|
||||
WP_CLI::log( sprintf( ' [OK] %s: %d group(s) clean (oldest check %s UTC)', $entity_type, count( $last ), $oldest ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run health check on all tables.
|
||||
*
|
||||
@@ -196,30 +281,12 @@ class TMDO_CLI {
|
||||
WP_CLI::log( $exists ? " [OK] {$t}" : " [NOT CREATED] {$t} (will be created on first use)" );
|
||||
}
|
||||
|
||||
// Check user entity flat tables (v2.5.5+).
|
||||
$user_entity_tables = array(
|
||||
$wpdb->prefix . 'wpdo_user_membership',
|
||||
$wpdb->prefix . 'wpdo_user_activity',
|
||||
$wpdb->prefix . 'wpdo_user_profile',
|
||||
$wpdb->prefix . 'wpdo_user_sso',
|
||||
$wpdb->prefix . 'wpdo_user_points_ledger',
|
||||
$wpdb->prefix . 'wpdo_migration_status',
|
||||
);
|
||||
foreach ( $user_entity_tables as $t ) {
|
||||
if ( TMDO_IS_SQLITE ) {
|
||||
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
|
||||
} else {
|
||||
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
|
||||
}
|
||||
if ( $exists ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
|
||||
WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
|
||||
} else {
|
||||
WP_CLI::warning( " [MISSING] {$t} — run \`wp wpdo install\` to create." );
|
||||
$all_ok = false;
|
||||
}
|
||||
// Entity flat tables, from the registry (was a hard-coded user list that
|
||||
// named the never-registered sso group).
|
||||
if ( ! self::check_entity_tables() ) {
|
||||
$all_ok = false;
|
||||
}
|
||||
self::report_guard_and_drift();
|
||||
|
||||
// Check partner plugin custom tables via Custom Table Registry (v2.6.0+).
|
||||
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
||||
@@ -330,6 +397,40 @@ 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;
|
||||
}
|
||||
|
||||
// ── 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 ) ) {
|
||||
@@ -634,51 +735,6 @@ class TMDO_CLI {
|
||||
WP_CLI::success( "Module {$module} disabled (idle)." );
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings from HP Custom Tables.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp wpdo import-hpct
|
||||
*
|
||||
* @param array $args Positional arguments.
|
||||
* @param array $assoc_args Associative arguments.
|
||||
* @return void
|
||||
*
|
||||
* @subcommand import-hpct
|
||||
*/
|
||||
public function import_hpct( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
|
||||
// HPCT is a HivePress-family plugin; the importer ships in that AddOn.
|
||||
if ( ! class_exists( 'TMDO_HPCT_Import' ) ) {
|
||||
WP_CLI::error( 'import-hpct needs 2meet-data-optimizer-hivepress-addon to be active.' );
|
||||
}
|
||||
|
||||
if ( TMDO_HPCT_Import::is_imported() ) {
|
||||
WP_CLI::warning( 'HPCT settings have already been imported.' );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! TMDO_HPCT_Import::can_import() ) {
|
||||
WP_CLI::error( 'HP Custom Tables plugin not detected or already imported.' );
|
||||
}
|
||||
|
||||
// Show preview.
|
||||
$preview_data = TMDO_HPCT_Import::preview();
|
||||
if ( ! empty( $preview_data['modules'] ) ) {
|
||||
WP_CLI::log( 'Import preview:' );
|
||||
WP_CLI\Utils\format_items( 'table', $preview_data['modules'], array( 'module', 'hpct_status', 'wpdo_state' ) );
|
||||
}
|
||||
|
||||
WP_CLI::confirm( 'Proceed with import?' );
|
||||
|
||||
$result = TMDO_HPCT_Import::run();
|
||||
if ( is_wp_error( $result ) ) {
|
||||
WP_CLI::error( $result->get_error_message() );
|
||||
}
|
||||
|
||||
WP_CLI::success( 'HPCT settings imported successfully. You can now deactivate HP Custom Tables.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run performance benchmark.
|
||||
*
|
||||
@@ -1396,22 +1452,6 @@ class TMDO_CLI {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the HPCT status string for display.
|
||||
*
|
||||
* @param array $compat Compatibility check result array.
|
||||
* @return string Human-readable HPCT status.
|
||||
*/
|
||||
private static function format_hpct_status( array $compat ): string {
|
||||
if ( $compat['hpct_active'] && $compat['hpct_imported'] ) {
|
||||
return 'Active (Imported)';
|
||||
}
|
||||
if ( $compat['hpct_active'] ) {
|
||||
return 'Active (Not imported — run `wp wpdo import-hpct`)';
|
||||
}
|
||||
return 'Not found';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the appropriate migration instance for a module name.
|
||||
*
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
"scripts": {
|
||||
"test": "phpunit --configuration phpunit.xml",
|
||||
"test:integration": "phpunit --configuration phpunit-integration.xml",
|
||||
"phpcs": "phpcs --standard=WordPress includes/ admin/ cli/ modules/ 2meet-data-optimizer.php uninstall.php",
|
||||
"phpcs": "phpcs --standard=phpcs.xml",
|
||||
"stan": "phpstan analyse --no-progress",
|
||||
"stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# 規模效能基準:原生 postmeta vs Hot 表 vs Entity 表
|
||||
|
||||
**量測日期**:2026-09-25 **分支**:`hardening/2.0` **目標規模**:vendor 100,000、listing 1,000,000
|
||||
|
||||
## 如何重現
|
||||
|
||||
```bash
|
||||
# DB 帳密沿用整合測試的 TMDO_TEST_DB_* 環境變數;資料庫名稱必須以 _bench 結尾
|
||||
TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/seed.php --vendors=100000 --listings=1000000 # 約 7.5 分鐘
|
||||
TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/run.php --iterations=20 # 約 20 分鐘
|
||||
```
|
||||
|
||||
## 環境與資料
|
||||
|
||||
| 項目 | 值 |
|
||||
|---|---|
|
||||
| 資料庫 | MariaDB 10.11.14(本機,InnoDB 預設設定) |
|
||||
| 主機 | 8 核、31 GB RAM、NVMe |
|
||||
| 資料 | 100,000 vendor(users + hp_vendor posts)、1,000,000 listing(90% publish) |
|
||||
| 原生 meta | postmeta 9,549,796 筆(951 MB)、usermeta 600,000 筆 |
|
||||
| Entity 表 | `wpdo_post_hp_listing_core` 1,000,000 筆(379 MB) |
|
||||
| Hot 表 | `wpdo_hot_hp_listing` 1,000,000 筆(229 MB) |
|
||||
|
||||
SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個條件一個 INNER JOIN、數值用 CAST、`SQL_CALC_FOUND_ROWS`),Hot 是 `TMDO_Query_Router`,Entity 是 `TMDO_Post_Query_Router`。每個情境 20 次,另有 3 次暖機不計入,參數隨機但可重現(`--seed=7`)。寫入在交易內執行後 rollback。
|
||||
|
||||
> 20 次取樣下 p95 等於最大值,僅供參考。
|
||||
|
||||
## 結果
|
||||
|
||||
| 情境 | 形式 | p50 ms | p95 ms | 結果筆數 | 使用索引 |
|
||||
|---|---|---:|---:|---:|---|
|
||||
| 1. 價格區間搜尋(命中約 20%) | 原生 | **14,695** | 14,890 | 180,292 | postmeta:post_id |
|
||||
| | Hot | 558 | 3,605 | 180,329 | h:ALL |
|
||||
| | Entity | 652 | 3,817 | 180,300 | e:ALL |
|
||||
| 2. 價格區間 + verified=1 | 原生 | **17,094** | 17,250 | 54,197 | postmeta:post_id ×2 |
|
||||
| | Hot | 277 | 299 | 54,109 | `idx_hp_verified_hp_price`(複合) |
|
||||
| | Entity | 337 | 340 | 54,180 | e:ALL |
|
||||
| 3. 某 vendor 名下的 listing | `post_parent`(HivePress 實際做法) | 0.19 | 0.25 | 7 | posts:post_parent |
|
||||
| | 原生 meta `hp_vendor` | **14,432** | 14,599 | 8 | postmeta:post_id |
|
||||
| | Hot | **3,405** | 3,651 | 10 | 無可用索引 |
|
||||
| | Entity | 0.28 | 0.33 | 8 | `idx_hp_vendor` |
|
||||
| 4. 單筆 listing 讀全部 meta | 原生 | 0.12 | 0.15 | 9 列 | postmeta:post_id |
|
||||
| | Hot / Entity | 0.09 | 0.11–0.14 | 1 列 | 主鍵 |
|
||||
| 5. 列表頁預載 20 筆 meta | 原生 | 0.59 | 0.61 | 181 列 | postmeta:post_id |
|
||||
| | Hot / Entity | 0.21–0.22 | 0.23–0.26 | 20 列 | 主鍵 |
|
||||
| 6. 單一 vendor 讀使用者 meta | 原生 | 0.11 | 0.14 | 6 列 | usermeta:user_id |
|
||||
| | Entity | 0.08 | 0.11 | 1 列 | 主鍵 |
|
||||
| 7. 寫入:更新一筆價格 | 原生 | 0.12 | 0.16 | — | — |
|
||||
| | Entity | 0.10 | 0.13 | — | — |
|
||||
| | dual_write(兩邊都寫) | 0.22 | 0.26 | — | — |
|
||||
|
||||
## 結論
|
||||
|
||||
1. **原生 meta_query 在百萬規模下無法使用。** 任何以 meta 為條件的搜尋都要 14–17 秒,扁平化是必要的,不是錦上添花。
|
||||
2. **扁平表的價值集中在「搜尋與篩選」。** 單筆或批次讀 meta(情境 4–6)三種形式都在 1 ms 內,差距可以忽略,而且實務上還有 object cache。
|
||||
3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。
|
||||
- **PR-028**:post 群組表可存 `post_status`、`post_date` 副本與 `(post_status, post_date)` 索引(`wp tmdo post-status-sync`)。1M 列實測:加欄位 0.05 秒(instant)、建索引 5.3 秒、分批(1,000 列)回填 32 秒,之後與 `posts` 0 筆不一致。欄位 collation 必須跟 `posts.post_status` 相同,否則比對會出現 illegal mix of collations。查詢改寫在 PR-029。
|
||||
- **PR-029**:副本 ready 且無 NULL 時,單一 post type、明確狀態、依日期排序的搜尋改為 INNER JOIN、`post_status IN (…)`、`ORDER BY` 副本 `post_date`、不 GROUP BY(bench 變體 `entity+copy`,`--iterations=5`)。仍帶 `SQL_CALC_FOUND_ROWS` 時:情境 1 p50 670→770 ms、**p95 3,739→773 ms**;情境 2 p50 356→448 ms(變慢,因為算總數仍要掃完所有符合列)。拿掉 `SQL_CALC_FOUND_ROWS` 後同形狀情境 1 只要 **8 ms**(走 `idx_post_status_date`、LIMIT 提早結束)→ 完整效益要搭配 PR-030。
|
||||
- **PR-030**:路由後的搜尋不再帶 `SQL_CALC_FOUND_ROWS`;總數改由同一查詢的 `COUNT(*)` 回答,存在物件快取,key 綁 `last_changed(posts)`(核心存文、Hook Bus 扁平寫入都會推進)。1M 實測:快取命中時整頁 **8 ms**(原 694 / 363 ms);快取失效後第一次為頁面 8 ms + COUNT 608 / 344 ms,與原本相當。任何 post 寫入都讓全部計數失效(WP last_changed 語意),寫入頻繁的站命中率較低。
|
||||
4. **Hot 表的索引規則有漏洞。** `add_covering_indexes()` 只替 decimal、tinyint,以及名稱以 `_time` 結尾的 bigint 建索引,所以 `hp_vendor` 沒有索引,情境 3 要 3.4 秒,比 Entity 慢約 12,000 倍。
|
||||
5. **Entity 表只有單欄索引。** 情境 2 輸給有複合索引的 Hot 表(337 ms 對 277 ms)。
|
||||
- **PR-027 之後**:群組可用 `register_group( ..., array( 'indexes' => ... ) )` 宣告複合索引。在 1M 列的 Entity 表加上 `cidx_verified_price (hp_verified, hp_price)` 花 8.1 秒;重跑情境 2(`--iterations=10`),Entity p50 從 337 ms 降到 **278 ms**(p95 285 ms),Hot 是 269 ms(p95 280 ms),差距在量測誤差內。執行計畫改走 `e:cidx_verified_price`。
|
||||
6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。
|
||||
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
|
||||
|
||||
## 前台與寫入鏈實測(dev36,Release 2.1,2026-09-26)
|
||||
|
||||
上面的數字是純 SQL。這一節是真實站台(dev36:HivePress 1.7.31 + Marketplace、WooCommerce 11.1.2、Redis object cache、listing 約 10 萬筆)上的整頁與整條寫入鏈,決策背景見 `adr-004-search-index-mode-for-hp-wc.md`。
|
||||
|
||||
**前台搜尋**(`tests/live/tools/fe.sh`:每個 URL 5 次取中位數,`X-S36-Ms` = 整個請求到 `template_redirect`)。「routed」是 post 模式 shadow_read + 核心 2.1 router + HivePress AddOn 0.2.0;「1.0.x」是整合測試當時的 router(報告 `notes/tmdo-integration-test-dev36-2026-09-26.md`)。
|
||||
|
||||
| URL | 原生 ms | 1.0.x ms | 2.1 routed ms | ids 與原生相同 |
|
||||
|---|---:|---:|---:|---|
|
||||
| listing 價格區間 | 614 | 496 | **323** | 是 |
|
||||
| listing 價格區間 + `_sort=price__asc` | 975 | 1,062 | **435** | 是 |
|
||||
| listing 時薪區間 + `_sort=hourly_rate__desc` | 850 | — | **367** | 是 |
|
||||
| listing `_sort=rating__desc` | 1,697 | 1,720 | **866** | 是 |
|
||||
| listing 關鍵字 + 價格區間 | 754 | — | 764 | 是 |
|
||||
| vendor 時薪區間 | 102 | — | 97 | 是 |
|
||||
| WC 商店價格篩選 / 價格排序 | 94 / 111 | 88–94 | 109 / 88 | 是(WC 用 lookup 表,不經 router) |
|
||||
|
||||
(R-2.1 驗收,扁平表已從原生回填、含 PR-062c;兩輪量測見 `notes/tmdo-acceptance-2.1-2026-09-26.md`。)
|
||||
|
||||
- HivePress 搜尋仍帶 tax_query(排除一個 term),WordPress 因此加上 `GROUP BY posts.ID`,排序需要 temporary + filesort;這是目前 311–428 ms 的主要成本,尚未達到 R-2.1 的 300 ms 目標。
|
||||
- PR-062b 之前,routed 搜尋因 `posts_request` 改寫而失去 WordPress 的 split query,改撈整列 `posts.*`:同一條 SQL 撈 `*` 要 590 ms,只撈 ID 為 312 ms。
|
||||
- PR-062c 之前,依扁平欄位排序的 routed 查詢由 `wp_posts`(type / status 索引,約 56k 列)驅動、逐筆查扁平表再以 temporary table 排序;拿掉 GROUP BY 或只換 tie-break 都無效(約 300 ms),改由扁平表驅動並把 tie-break 改讀 `post_id` 後只剩單表 filesort:`meta_value_num` 263→147 ms、具名排序 250→142 ms、HP 時薪篩選 + 排序 300→36 ms(只計 SQL)。依日期排序的搜尋由扁平表驅動反而變慢(74→137 ms),所以 flat-first 只用在扁平欄位排序。
|
||||
- 量測 gotcha:symlink 切換(`plugins/` → `plugins-dev`)後,PHP-FPM 的 opcache(`revalidate_path=0`)仍執行舊目標的程式,WP-CLI 不受影響;前台數字要先確認實際載入的檔案。
|
||||
|
||||
**WP_Query**(`tests/live/tools/qbench.php`,`fields=ids`,20 筆一頁):價格區間 296→76 ms、價格 + verified 360→53 ms、rating ≥ 4.5 248→33 ms、rate + `_thumbnail_id` EXISTS 24→9 ms;OR、多 post type、無 type 數值比較等維持原生,結果與原生相同。
|
||||
|
||||
**寫入鏈開銷**(單一程序,disabled 與 dual_write 交替兩輪取平均):
|
||||
|
||||
| 寫入 | disabled | dual_write | 增加 |
|
||||
|---|---:|---:|---:|
|
||||
| HP listing | 754 ms / 348 q | 976 ms / 434 q | +29% |
|
||||
| WC product | 215 ms / 129 q | 489 ms / 206 q | +127% |
|
||||
| vendor(user + post) | 444 ms / 162 q | 630 ms / 230 q | +42% |
|
||||
|
||||
WC product 的成本來自 `wc_product` 群組,Release 2.2 移除 WC 扁平化後消失。
|
||||
|
||||
## 優化候選(待使用者確認後排入 backlog)
|
||||
|
||||
| # | 候選 | 針對 | 預期效果 |
|
||||
|---|---|---|---|
|
||||
| O-1 | Entity 表把 `post_status`、`post_date` 一併存進來,並建 `(hp_status, post_date)` 等複合索引,讓搜尋不必 JOIN 回 `posts` 排序 | 情境 1、2 | 寬範圍搜尋改為走索引排序,避免 filesort |
|
||||
| O-2 | Entity 表支援群組層級的複合索引宣告,例如 `(hp_verified, hp_price)`、`(hp_featured, hp_price)` | 情境 2 | 追上或超過 Hot 表 |
|
||||
| O-3 | Hot 表的索引規則補上外鍵型 bigint 欄位(例如 `hp_vendor`) | 情境 3 | 3.4 秒降到 1 ms 以下 |
|
||||
| O-4 | 提供總數快取,或讓路由後的查詢可以不跑 `SQL_CALC_FOUND_ROWS`(MySQL 8 已棄用) | 情境 1、2 | 省去每次搜尋都要算出全部符合列的成本 |
|
||||
| O-5 | 評估 Hot 表與 Entity 表的整併,避免同一份資料存兩份 | 容量、寫入 | 省下約 229 MB 並減少一次寫入 |
|
||||
@@ -1,5 +1,7 @@
|
||||
# Integration Pattern Decision — 2026-04-25
|
||||
|
||||
> **2026-09 更新:** 2meet 自家外掛回到「partner 自行註冊」,做法見 `PARTNER-REGISTRATION.md`;對應的 8 個 AddOn 已淘汰。
|
||||
|
||||
> **v1.0.0 後記(2026-07-31):本文結論已被架構拆分取代。**
|
||||
> 當時的兩個選項是「集中在核心」vs「各外掛自帶 bridge」。v1.0.0 走的是第三條路:
|
||||
> 每個夥伴外掛對應**一個獨立 AddOn 外掛**(`2meet-data-optimizer-<partner>-addon`,共 11 個),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# Partner 外掛自行註冊指南
|
||||
|
||||
> 適用:2meet 自家外掛(bookings、collab、hub、infocards、mobile-bridge、playlist、quotation、spoke)。
|
||||
> 這 8 個外掛原本各有一個 `2meet-data-optimizer-<slug>-addon`,替它們向核心註冊資料表和欄位。
|
||||
> 這些 AddOn 已淘汰:資料結構的定義應該和資料的擁有者放在一起,所以改由 partner 外掛自己註冊。
|
||||
> 啟用中的 AddOn 會在後台顯示淘汰通知(`TMDO_Deprecated_Addons_Notice`)。
|
||||
>
|
||||
> HivePress、WooCommerce、LatePoint 是第三方外掛,仍然保留各自的 AddOn。
|
||||
|
||||
## 1. 掛上 trait
|
||||
|
||||
核心提供 `TMDO_Anti_EAV_Aware` trait(`includes/trait-tmdo-anti-eav-aware.php`)。trait 由核心載入,所以只有在核心存在時才宣告 bridge 類別,把它放在獨立檔案:
|
||||
|
||||
```php
|
||||
// 2meet-bookings.php(partner 主檔)
|
||||
add_action(
|
||||
'plugins_loaded',
|
||||
static function () {
|
||||
if ( ! trait_exists( 'TMDO_Anti_EAV_Aware' ) ) {
|
||||
return; // 核心未啟用:什麼都不做。
|
||||
}
|
||||
require_once __DIR__ . '/includes/class-tmb-tmdo-bridge.php';
|
||||
( new TMB_TMDO_Bridge() )->boot();
|
||||
},
|
||||
3
|
||||
);
|
||||
```
|
||||
|
||||
```php
|
||||
// includes/class-tmb-tmdo-bridge.php
|
||||
final class TMB_TMDO_Bridge {
|
||||
use TMDO_Anti_EAV_Aware;
|
||||
|
||||
protected function plugin_slug(): string {
|
||||
return '2meet-bookings';
|
||||
}
|
||||
|
||||
public function boot(): void {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
// 覆寫需要的 on_register_*(),見下面兩節。
|
||||
}
|
||||
```
|
||||
|
||||
**時機**:核心在 `plugins_loaded:4` 觸發 `wpdo_register_*`,並在 `init:1` 再觸發一次。
|
||||
建議掛在 `plugins_loaded:3`,這樣第一次觸發就能收到你的註冊,`init` 之前的讀寫也會被正確路由。
|
||||
掛在 `:5` 之後也能運作(會在 `init:1` 補上),只是 `init` 之前的請求看不到你的欄位。
|
||||
|
||||
**名稱**:請用 `TMDO_Anti_EAV_Aware`。`WPDO_Anti_EAV_Aware` 是舊的 wp-data-optimizer 3.x 的 trait,由 mu-plugin `wpdo-policy-enforcer.php` 從已退役的路徑載入,沒有後面提到的 `post_types` 參數。
|
||||
|
||||
## 2. 註冊自有資料表
|
||||
|
||||
讓 `wp wpdo doctor` / benchmark 認得你的資料表,並做 schema 漂移檢查。以 bookings 為例:
|
||||
|
||||
```php
|
||||
public function on_register_custom_tables( TMDO_Custom_Table_Registry $r ): void {
|
||||
$this->register_custom_table(
|
||||
$r,
|
||||
array(
|
||||
'table_name' => '2mb_services', // 不含 $wpdb->prefix
|
||||
'primary_key' => 'id',
|
||||
'post_type_link' => null, // 若每列對應一個 post,填 post_type(playlist 用 'hp_vendor')
|
||||
'expected_columns' => array(
|
||||
'id' => 'BIGINT',
|
||||
'vendor_id' => 'BIGINT',
|
||||
'title' => 'VARCHAR',
|
||||
// …照 CREATE TABLE 逐欄列出
|
||||
),
|
||||
'indexes' => array(
|
||||
'vendor_id' => array( 'vendor_id' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
collab、playlist、quotation、mobile-bridge 的 AddOn 只做這件事:把各自 `includes/class-tmdo-<slug>.php` 裡 `register_custom_tables()` 的 `$tables` 陣列**原樣**搬進 partner 的 bridge 即可。
|
||||
|
||||
## 3. 註冊 entity 欄位群組(選用)
|
||||
|
||||
只有要把 meta 從 `wp_postmeta` / `wp_usermeta` 等 EAV 表移到扁平表時才需要:
|
||||
|
||||
```php
|
||||
public function on_register_entity_fields(): void {
|
||||
$this->register_entity_fields(
|
||||
'post',
|
||||
'tmb_service_core',
|
||||
array(
|
||||
array( 'key' => 'tmb_price', 'type' => 'decimal', 'searchable' => true ),
|
||||
array( 'key' => 'tmb_status', 'type' => 'enum', 'options' => array( 'open', 'closed' ) ),
|
||||
),
|
||||
array( 'post_types' => array( 'tmb_service' ) )
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- **`post_types`(post 群組建議必填)**:群組只套用在這些 post type。多個群組共用同一個 meta key 時,會依 post 本身的類型決定寫到哪張表;不屬於這些 post type 的 post,這個 key 不受管理(照舊寫原生 meta)。沒有宣告時,共用 key 會被「最後註冊的群組」整個接走。
|
||||
- **定義一旦上線就不要改**:欄位定義的 hash 存在 `wpdo_registry_meta`,改了會對已有資料的扁平表執行 ALTER。要新增欄位時,開新群組。
|
||||
- **群組名稱不可和別人重複**:同名但定義不同時,後註冊的會被忽略,並在 Site Health、後台 Doctor 分頁和 `wp wpdo doctor` 顯示 `[COLLISION]`。
|
||||
|
||||
## 4. 從 AddOn 遷移
|
||||
|
||||
1. 在 partner 的 bridge 裡註冊和 AddOn **完全相同**的資料表與群組(直接複製 AddOn 的定義)。
|
||||
2. 部署 partner 新版,AddOn 先保持啟用。`wp wpdo doctor` 不應出現 `[COLLISION]`;兩邊定義相同時,重複註冊會被靜默忽略。
|
||||
3. 停用 AddOn。
|
||||
4. 再跑一次 `wp wpdo doctor`:資料表仍在清單中,而且不應出現 `[ORPHAN]`。如果出現,表示有群組沒有被 partner 接手;該實體是 aeav_only 時,核心會依 `wpdo_registry_meta` 暫時還原它,讓資料可以讀取,但仍應補上註冊。
|
||||
5. 確認沒有問題後,刪除 AddOn 外掛。
|
||||
|
||||
hub、infocards、spoke 的 partner 外掛本身已經會自己註冊,AddOn 只是重複註冊;停用前照第 2 步確認沒有 `[COLLISION]` 即可。
|
||||
|
||||
## 相關文件
|
||||
|
||||
- `ENTITY_ADAPTER_COOKBOOK.md`:整合深度 Tier 1–3(`TMDO_API` facade、Schema Registry、自訂 adapter)。
|
||||
- `INTEGRATION_PATTERN_DECISION.md`:2026-04 的「partner 自帶 bridge」決策紀錄;本指南回到這個做法。
|
||||
@@ -0,0 +1,78 @@
|
||||
# ADR-003:Hot zone 與 Entity 表收斂
|
||||
|
||||
- **狀態**:已接受(2026-09-26,選項 A)
|
||||
- **日期**:2026-09-26
|
||||
- **相關**:`BENCHMARK-SCALE.md`(O-3、O-5)、backlog PR-025〜PR-030
|
||||
|
||||
## 背景
|
||||
|
||||
post 的高頻欄位有兩套扁平化機制,資料可能存兩份:
|
||||
|
||||
| | Hot zone | Entity bridge |
|
||||
|---|---|---|
|
||||
| 表 | `wp_wpdo_hot_{post_type}`(每個 post type 一張) | `wp_wpdo_post_{group}`(每個群組一張) |
|
||||
| 註冊 | `TMDO_Schema_Registry`(zone = hot) | `TMDO_Entity_Registry::register_group()` |
|
||||
| 寫入 | `TMDO_Sync_Bridge`(zone 模組進入 dual_write 後) | `TMDO_Hook_Bus`(實體 mode ≥ dual_write) |
|
||||
| 查詢改寫 | `TMDO_Query_Router`(`hot_*` 模組 cutover 後) | `TMDO_Post_Query_Router`(post mode ≥ shadow_read) |
|
||||
| 生命週期 | 每個 zone 模組 7 狀態 FSM | 每個實體 4 模式(disabled → aeav_only) |
|
||||
| 涵蓋實體 | 只有 post | post、user、term、comment |
|
||||
| 可完全取代原生 meta | 否(cutover 後原生仍保留) | 是(aeav_only) |
|
||||
| 其他 | 單欄 `indexed`、自訂 `data_type` | post_type 範圍(PR-033)、schema hash、衝突偵測、孤兒還原、回寫原生(PR-017/018/031) |
|
||||
|
||||
Warm(瀏覽數 TTL)、Cold(JSON blob)、Archive(已刪除文章的 meta)是 Entity 沒有的能力,**不在本決策範圍**,保持原狀。
|
||||
|
||||
## 事實
|
||||
|
||||
**效能**(100k vendor / 1M listing,`BENCHMARK-SCALE.md`):
|
||||
|
||||
| 情境 | Hot | Entity |
|
||||
|---|---:|---:|
|
||||
| 價格區間搜尋 p50 | 558 ms | 652 ms |
|
||||
| 價格區間 + verified p50 | **277 ms**(有複合索引) | 337 ms |
|
||||
| vendor 名下 listing p50 | 3,405 ms(無索引) | **0.28 ms** |
|
||||
| 單筆 / 列表預載 | 相同 | 相同 |
|
||||
| 容量 | 229 MB | 379 MB |
|
||||
|
||||
唯一 Hot 領先的是有複合索引的情境;Entity 支援複合索引後(PR-027)即可追平。
|
||||
|
||||
**各站使用現況**(2026-09-26 唯讀查詢):
|
||||
|
||||
| 站 | Hot / Cold 表 | zone 模組 | post entity |
|
||||
|---|---|---|---|
|
||||
| dev20 / dev21 / dev22 | 無 | 全 idle | aeav_only(dev21 約 400 萬列) |
|
||||
| dev23 / 24 / 30 / 31 / 40 | 無 | 全 idle | disabled |
|
||||
| dev34(舊 WPDO 1.3.37) | hot_hp_listing 486、hot_hp_vendor 233 | cutover | disabled |
|
||||
| dev10 DB(舊版) | hot_hp_listing 491、hot_hp_vendor 231 | cutover | aeav_only(約 3,900 列) |
|
||||
|
||||
- **沒有任何 2meet-DO 站在用 Hot zone**;有扁平資料的站全部走 Entity。
|
||||
- 只有兩個舊系統的站有 Hot 資料,而它們在 cutover 仍雙寫 postmeta,Hot 表只是副本(PR-022 已確認以 postmeta 為準)。
|
||||
|
||||
**只由 Hot zone 註冊、Entity 群組沒有涵蓋的 HP 欄位**:listing / request 的 `hp_drafted`;bookings 的 `hp_start_time`、`hp_end_time`、`hp_quantity`;memberships 的 `hp_expire_period`、`hp_primary`、`hp_expired_time`。
|
||||
|
||||
## 選項
|
||||
|
||||
**A. 收斂到 Entity(建議)**
|
||||
post 高頻欄位只用 Entity。Hot zone 凍結:不再新增註冊,程式碼暫留只為讀取舊資料,確認沒有站使用後再移除。
|
||||
|
||||
- 優點:一套路由、一份資料;Entity 能力是 Hot 的超集;現況已經全部在用 Entity,不需要搬資料。
|
||||
- 代價:Hot 獨有的欄位(上面那幾個)要改成新的 Entity 群組;情境 2 要靠 PR-027 的複合索引追平。
|
||||
|
||||
**B. 兩套並存**
|
||||
維持現狀。代價是兩套寫入和查詢路徑都要維護,同一個 key 可能存兩份,O-3 之類的最佳化要做兩次。
|
||||
|
||||
**C. 收斂到 Hot**
|
||||
不可行:Hot 只支援 post,也沒有 aeav_only、範圍宣告、回寫工具。
|
||||
|
||||
## 建議(選項 A)的後續步驟
|
||||
|
||||
1. **PR-026(Hot 表 bigint 索引)標為 `[!]` 不做**:沒有站在用 Hot zone。
|
||||
2. **PR-027(Entity 複合索引)照常做**:這是情境 2 追平 Hot 的方式。
|
||||
3. **新增 PR(HP AddOn)**:把 Hot 獨有的欄位改註冊成新的 Entity 群組(例如 `hp_booking_core`、`hp_membership_core`,以及 listing / request 的補充群組);既有群組的定義不能改。
|
||||
4. **新增 PR(HP AddOn)**:HP adapter 停止註冊 hot 欄位;核心對 zone = hot 的新註冊記錄 deprecated 警告。
|
||||
5. **之後**:dev34(PR-022)和 dev10 DB(PR-023)遷出後,再評估移除 `TMDO_Zone_Hot`、`TMDO_Sync_Bridge` 的 hot 路徑與 `TMDO_Query_Router`。
|
||||
|
||||
## 決定
|
||||
|
||||
**選項 A:收斂到 Entity。**(使用者 2026-09-26 決定)
|
||||
|
||||
Hot zone 凍結,依上面的後續步驟執行;backlog 已同步調整(PR-026 標為 `[!]`,新增 HP AddOn 的兩個 PR)。
|
||||
@@ -0,0 +1,120 @@
|
||||
# ADR-004:HivePress / WooCommerce 站採「搜尋索引模式」
|
||||
|
||||
- **狀態**:已接受(2026-09-26)
|
||||
- **日期**:2026-09-26
|
||||
- **相關**:dev36 整合測試報告(`notes/tmdo-integration-test-dev36-2026-09-26.md`)、ADR-001、ADR-003、backlog PR-052〜PR-063(Release 2.1)
|
||||
|
||||
## 背景
|
||||
|
||||
2026-09-26 在 dev36(HivePress 1.7.31 + Marketplace + 10 個擴充、WooCommerce 11.1.2、Redis、10 萬筆 listing)做整合與壓力測試,確認:
|
||||
|
||||
1. **HivePress model 與 WC data store 用空 key 讀 meta**(`get_post_meta( $id )`),1.0.x 的 Hook Bus 對空 key 放行、只讀原生。所以 aeav_only 或 cleanup 之後,HP 的 verified / rating、WC 的價格與顧客資料讀成空值(dev20 / dev21 盤點:WC 商品 0/20 可購買)。
|
||||
2. **WC 以直接 SQL 更新 `_stock`、`total_sales`**,扁平表跟不上。
|
||||
3. **shadow_read 會遺失寫入**:WP 的 `update_metadata()` 經 `get_metadata_raw` 讀舊值,攔截器從扁平表回答,新舊相同就不寫原生。
|
||||
4. **Router 在 OR、LIKE、多 post type、跨 group、具名 orderby 上結果錯誤**,HivePress 的 `_sort` 沒被改寫,前台搜尋幾乎沒有變快。
|
||||
5. **陣列值被存成 `"Array"`**。
|
||||
|
||||
帶 key 的單筆讀取本來就沒有速度優勢:`update_meta_cache()` 一次載入全部 key,之後都是記憶體讀取。扁平表真正的價值在**搜尋與篩選**(`BENCHMARK-SCALE.md` 結論 1、2)。
|
||||
|
||||
## 決策(使用者 2026-09-26)
|
||||
|
||||
1. **HP / WC 站採「搜尋索引模式」**:原生 meta 是唯一真實資料來源,扁平表是它的搜尋索引。模式最高到 `shadow_read`;`aeav_only` 與 cleanup 由 Promotion Guard 擋下。
|
||||
2. **移除 WC 扁平化**(Release 2.2:core 的 `post/wc_product` 群組、WC AddOn 的 user `hot` 群組與 hot-zone 欄位)。
|
||||
3. **既有 aeav_only 站(dev20 / 21 / 22)先唯讀盤點**,處置由使用者決定(`notes/ops-aeav-sites-audit-2026-09-26.md`)。
|
||||
4. **分兩版**:2.1 = 止血 + 資料正確性 + Router 與 HP 排序;2.2 = 範圍收斂 + 漂移偵測 + CLI 與診斷。
|
||||
|
||||
## 設計(Release 2.1)
|
||||
|
||||
### 寫入:扁平表跟隨原生
|
||||
|
||||
- **dual_write / shadow_read** 下,pre-filter(`update_/add_/delete_{type}_metadata`)只做路由判斷後回 `null`,讓 WordPress 自己寫原生。
|
||||
- 扁平表在 `added_ / updated_ / deleted_{type}_meta` action(priority 1)裡**從原生重新同步**:直接 SQL 讀原生第一筆 row(`ORDER BY meta_id LIMIT 1`,不經 filter),扁平值 = `to_db( maybe_unserialize( v ) )`,原生沒有 row 時設為 NULL(`TMDO_Hook_Bus::sync_from_native()`)。
|
||||
- `delete_metadata( …, $delete_all )`:`deleted_` action 拿到的 object id 是 0,所以在 `delete_{type}_meta` 先記下 object ids。
|
||||
- `update_metadata_by_mid()` 改 key:新舊兩個 key 都重新同步。
|
||||
- 原生沒有值時只把既有列的欄位設為 NULL,**不新增列**:刪除實體時 adapter 先刪扁平列,WordPress 才逐筆刪 meta。
|
||||
- **aeav_only** 保留原本的 pre-filter short-circuit(非 HP / WC 站仍可使用)。
|
||||
|
||||
### 讀取:shadow_read = 讀原生、抽樣比對扁平
|
||||
|
||||
`intercept_get` 在 shadow_read 回 `$check`(讓 WordPress 讀原生),並以 `tmdo_shadow_compare_sample_rate`(預設 0.1)抽樣比對扁平值、記錄差異。查詢加速不受影響,因為 Router 仍由 `reads_from_flat()`(shadow_read 以上)控制。
|
||||
|
||||
### Promotion Guard
|
||||
|
||||
`TMDO_Promotion_Guard`,由 `TMDO_Mode_Manager::set()`、`TMDO_Post_Migration::cleanup()` 與 `TMDO_Phase_Cleanup::execute()` 呼叫:
|
||||
|
||||
- HivePress 啟用時,post / user / term / comment 不得升到 aeav_only 或執行 cleanup;WooCommerce 啟用時,post / user 不得。
|
||||
- 回傳 `WP_Error( 'promotion_guarded' )`;特殊情況用 filter `tmdo_promotion_guard_allow` 放行。
|
||||
- 已經在 aeav_only 的站**讀取時不強制降級**(降級會讀到空的原生),只在後台顯示 notice。處置用 `wp tmdo bridge-restore-native <entity> --demote`。
|
||||
|
||||
### 回寫原生(bridge-restore-native)
|
||||
|
||||
依模式決定策略:aeav_only 預設 `overwrite`(寫入只到扁平表、原生已過期),其他模式預設 `only_missing`(原生可能較新,例如 WC 直接 SQL 寫庫存)。相同的值不重寫;扁平值等於欄位 default 且原生沒有 row 時不寫(不捏造預設值)。
|
||||
|
||||
### 序列化值
|
||||
|
||||
`text` / `textarea` 遇到陣列或物件時 `maybe_serialize()`,讀出時 `TMDO_Safe_Unserialize`;`text`(VARCHAR(255))超長存 NULL 並記 `text_overflow`。既有的 `"Array"` 由 `wp tmdo repair-array-literals` 從原生修復。
|
||||
|
||||
### Router:只在語意與原生相同時路由
|
||||
|
||||
`TMDO_Post_Query_Router`(`pre_get_posts` priority 1000,排在外掛於預設 priority 加的條件之後):
|
||||
|
||||
- **全有或全無**:任何一個已註冊 key 的 clause 不能路由,整個查詢交回 WordPress。不能路由的情況:
|
||||
- top-level OR
|
||||
- 含已註冊 key 的巢狀 group(排序形狀除外)
|
||||
- 多個 post type
|
||||
- 白名單以外的 compare
|
||||
- type 與欄位語意不同:無 type(CHAR)的數值範圍比較、decimal 欄位的 CHAR 比較、text 欄位用 NUMERIC
|
||||
- **LIKE** 包成 `%esc_like%`。
|
||||
- **每個 group 各自 LEFT JOIN**;扁平表一文章一列(UNIQUE `post_id`),不加 GROUP BY。
|
||||
- 狀態 / 日期副本路由遇到 NOT EXISTS 時停用:INNER JOIN 會漏掉沒有扁平列的文章。
|
||||
- **排序**(`wpdo_post_sort`):
|
||||
- **HivePress `_sort` 形狀**:OR 裡剛好是同 key、同 type 的 NOT EXISTS + EXISTS,其中一個具名,並且是唯一的 orderby。這個 group 從 meta_query 移除,改成 LEFT JOIN 扁平表並 `ORDER BY col, ID DESC`;ASC 時 NULL 排最前,與原生相同。
|
||||
- **具名 clause** 作為唯一的 orderby。
|
||||
- **`meta_key` + `meta_value_num`**(字串欄位用 `meta_value`),加上 `col IS NOT NULL`(原生是 INNER JOIN)。
|
||||
- 欄位排序不等於 `CAST( meta_value AS type )` 時退回原生。
|
||||
- **flat-first**:依扁平欄位排序、且 routed 條件已要求扁平列存在(同一 group、非 NOT EXISTS)、沒有 `post__in` / `author` 等高選擇性條件時,改寫成 `SELECT STRAIGHT_JOIN … FROM flat INNER JOIN wp_posts`,ORDER BY 的 `wp_posts.ID` 改讀等值的 `post_id`,排序只在一張表完成。依日期排序的搜尋不套用(由 posts 驅動較快)。filter `tmdo_router_flat_first` 可關閉。
|
||||
- **維持 WordPress 的 split query**:`posts_request` 移除 `SQL_CALC_FOUND_ROWS`(總數改由快取的 COUNT 回答)會讓 WordPress 以為 request 被改過而撈整列。`split_the_query` 對 routed 查詢恢復 ID-first,`posts_request_ids` 再移除一次 `SQL_CALC_FOUND_ROWS`。
|
||||
|
||||
HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `optimize_query` / `optimize_search`。
|
||||
|
||||
## 設計(Release 2.2)
|
||||
|
||||
### 範圍收斂
|
||||
|
||||
- core 不再註冊 `post/wc_product`;WooCommerce AddOn 0.2.0 不再註冊 product / order / subscription 的 zone 欄位與 user `hot` 群組。WooCommerce 的 meta 只存在原生。既有表以 `bridge-restore-native post --group=wc_product --retire` 封存。
|
||||
|
||||
### 漂移偵測與修復(`TMDO_Drift_Check`)
|
||||
|
||||
- `wp tmdo drift-check <entity>` 以值比對扁平與原生(第一筆原生 row → 安全 unserialize → `Type_Caster::to_db`),逐 key 回報 miss / diff / extra,結果寫入 `wpdo_drift_status`。
|
||||
- `--fix` 從原生重建漂移列,刪除已刪實體與非本 group post type 的列,不寫原生。
|
||||
- **aeav_only 不適用**:那時扁平才是真實資料,原生本來就過期。`--fix` 拒絕、每日抽樣略過、Router 不因漂移退回原生。
|
||||
- **Router**:上次檢查有漂移的 group 不路由,整個查詢維持原生;從未檢查的 group 照常路由(決策 2026-09-27:升級後不變慢)。
|
||||
- **每日抽樣**:`wpdo_daily_health_check`,每 group 500 筆。
|
||||
- **Hook Bus 關閉期間的寫入**:entity 從 disabled 進入 dual_write / shadow_read、或 Hook Bus flag 由關轉開時,60 秒後 `tmdo_drift_resync` 執行全量 `--fix`。不用 `post_modified_gmt` 增量,因為 WooCommerce 直接 SQL 寫 meta 不會更新它。
|
||||
- **Auto-promoter** 以 `clean_since()`(2 天內乾淨的檢查)取代 shadow diff 數量;Promotion Guard 仍擋 HP / WC 站。
|
||||
|
||||
### 查詢
|
||||
|
||||
- 純排序搜尋(HivePress `_sort`、沒有路由篩選)在該 post type 每篇都有扁平列時改由扁平表驅動;Router 在 `wp_insert_post` 替新文章建空列,覆蓋率快取 15 分鐘。
|
||||
|
||||
## 後果
|
||||
|
||||
**好處**
|
||||
|
||||
- 資料正確性回到 WordPress 原生語意:HP / WC 的空 key 讀取、直接 SQL 寫入都不再造成資料不一致或遺失。
|
||||
- 查詢加速保留,而且擴及 HivePress 前台的篩選與排序(實測見 `BENCHMARK-SCALE.md`「前台與寫入鏈實測」)。
|
||||
- 任何時候都可以降回 disabled,原生資料完整。
|
||||
|
||||
**代價與限制**
|
||||
|
||||
- **寫入開銷**:dual_write / shadow_read 每次 meta 寫入多一次原生讀取與一次扁平寫入。dev36 實測 HP listing 整條寫入鏈 +29%、WC product +127%(後者在 2.2 移除 WC 扁平化後消失)。
|
||||
- **沒有讀取加速**:帶 key 的單筆讀取仍走原生(WordPress meta cache)。
|
||||
- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 起由 `drift-check`(每日抽樣、Router 避開漂移中的 group)與重新啟用時的全量 `--fix` 處理;兩次抽樣之間的漂移仍可能短暫回傳過期結果,大量匯入後可手動執行 `drift-check <entity> --fix`。
|
||||
- **decimal 欄位搭配 NUMERIC / DECIMAL**:原生先 CAST 成整數(`DECIMAL` 預設 (10,0)),扁平表精確比較,因此帶小數的值在區間邊界上可能不同。dev36 資料沒有這種值。
|
||||
- **text 欄位超過 255 字元**存 NULL,這類值的篩選結果會與原生不同(有 `text_overflow` 記錄)。
|
||||
- HivePress 搜尋 p50 為 321–866 ms(原生 600–1,713 ms),尚未達到 R-2.1 的 300 ms 目標(驗收:`notes/tmdo-acceptance-2.1-2026-09-26.md`)。價格篩選頁的 SQL 只剩約 75 ms、其餘為頁面渲染;純排序(如 `rating__desc`)必須 LEFT JOIN 保留沒有值的文章,無法 flat-first。
|
||||
|
||||
## 不在範圍內
|
||||
|
||||
- aeav_only 的空 key 讀取合併(B02):HP / WC 站不再使用 aeav_only,是否實作依其他站需求決定。
|
||||
- Hot zone 的移除(ADR-003 的後續步驟)。
|
||||
@@ -284,7 +284,6 @@ class TMDO_Module_Detector {
|
||||
$active = match ( $plugin ) {
|
||||
'hivepress' => TMDO_Compatibility::is_hivepress_active(),
|
||||
'woocommerce' => TMDO_Compatibility::is_woocommerce_active(),
|
||||
'hpct' => TMDO_Compatibility::is_hpct_active(),
|
||||
'latepoint' => TMDO_Compatibility::is_latepoint_active(),
|
||||
default => false,
|
||||
};
|
||||
|
||||
@@ -29,52 +29,10 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
class TMDO_Module_Rules {
|
||||
|
||||
/**
|
||||
* Default rules for the 15 known modules. Use `get()` to apply filter.
|
||||
* Default rules for the 8 known modules. Use `get()` to apply filter.
|
||||
*/
|
||||
private const DEFAULT_RULES = array(
|
||||
// ─── HPCT modules(HivePress + HPCT)────────────────────────────
|
||||
'reviews' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_review',
|
||||
'min_post_count' => 100,
|
||||
'description' => '若使用 HivePress reviews 且累積評論 ≥ 100 條,啟用後可顯著降低 listing 查詢的 JOIN 成本。',
|
||||
),
|
||||
'messages' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_message_thread',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress messages 累積對話數 ≥ 50 時,啟用 module 可加速 inbox / unread count 查詢。',
|
||||
),
|
||||
'favorites' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_favorite',
|
||||
'min_post_count' => 200,
|
||||
'description' => '使用者 favorite 累積 ≥ 200 條時,啟用 module 把 favorite meta 從 wp_postmeta 搬出。',
|
||||
),
|
||||
'memberships' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_membership',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress memberships 啟用且 ≥ 50 條訂閱時,membership 過期檢查會更快。',
|
||||
),
|
||||
'statistics' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 500,
|
||||
'description' => 'Listing ≥ 500 條時,view_count / favorite_count 等高頻統計搬到 stats module 可大幅減少 wp_postmeta 寫入。',
|
||||
),
|
||||
'requests' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_request',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress requests / quotes 累積 ≥ 50 條時建議啟用。',
|
||||
),
|
||||
'listing_meta' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 100,
|
||||
'description' => '所有 listing meta 集中管理;listing ≥ 100 時可省下大量 postmeta JOIN。',
|
||||
),
|
||||
// ─── AddOn modules ──────────────────────────────────────────────
|
||||
'wc_orders' => array(
|
||||
'compat_required' => array( 'woocommerce' ),
|
||||
'post_type_required' => 'shop_order',
|
||||
|
||||
@@ -40,7 +40,7 @@ class TMDO_Cache_Layer {
|
||||
}
|
||||
|
||||
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
|
||||
if ( empty( $cold_keys ) ) {
|
||||
if ( empty( $cold_keys ) || ! self::cold_active( $post_type ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class TMDO_Cache_Layer {
|
||||
// Check which IDs are already cached.
|
||||
$uncached = array();
|
||||
foreach ( $post_ids as $pid ) {
|
||||
$cached = wp_cache_get( "cold_{$pid}", $group );
|
||||
$cached = wp_cache_get( TMDO_Zone_Cold::cache_key( (int) $pid, $post_type ), $group );
|
||||
if ( false === $cached ) {
|
||||
$uncached[] = (int) $pid;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ class TMDO_Cache_Layer {
|
||||
// Cache all results (including empty arrays for posts with no cold data).
|
||||
foreach ( $uncached as $pid ) {
|
||||
$data = $fetched[ $pid ] ?? array();
|
||||
wp_cache_set( "cold_{$pid}", $data, $group, $ttl );
|
||||
wp_cache_set( TMDO_Zone_Cold::cache_key( $pid, $post_type ), $data, $group, $ttl );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,33 +108,46 @@ class TMDO_Cache_Layer {
|
||||
*/
|
||||
public static function warm_post( int $post_id, string $post_type ): void {
|
||||
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
|
||||
if ( empty( $cold_keys ) ) {
|
||||
if ( empty( $cold_keys ) || ! self::cold_active( $post_type ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force a fresh read from DB (bypasses cache).
|
||||
$group = 'wpdo_cold_' . sanitize_key( $post_type );
|
||||
wp_cache_delete( "cold_{$post_id}", $group );
|
||||
wp_cache_delete( TMDO_Zone_Cold::cache_key( $post_id, $post_type ), $group );
|
||||
|
||||
// Re-read will populate cache.
|
||||
TMDO_Zone_Cold::get_blob( $post_id, $post_type );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cold zone holds data for this post type: its module writes
|
||||
* to the cold table and the table exists. Registered cold fields alone do
|
||||
* not mean either (zero side effect while the module is idle).
|
||||
*
|
||||
* @param string $post_type Post type.
|
||||
* @return bool
|
||||
*/
|
||||
private static function cold_active( string $post_type ): bool {
|
||||
return TMDO_Feature_Flags::is_write_active( TMDO_Zone_Router::module_name( 'cold', $post_type ) )
|
||||
&& TMDO_Schema_Manager::table_exists( TMDO_Zone_Cold::table( $post_type ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all cold cache for a post type.
|
||||
*
|
||||
* Useful after bulk imports or schema changes.
|
||||
* Useful after bulk imports or schema changes. Bumps the post type's cache
|
||||
* version (new keys from now on) instead of wp_cache_flush_group(), which
|
||||
* Redis Object Cache runs as one server-side scan that blocks Redis on a
|
||||
* large group; older entries expire by their TTL.
|
||||
*
|
||||
* @param string $post_type Post type to flush.
|
||||
*/
|
||||
public static function flush_group( string $post_type ): void {
|
||||
$group = 'wpdo_cold_' . sanitize_key( $post_type );
|
||||
|
||||
if ( function_exists( 'wp_cache_flush_group' ) ) {
|
||||
wp_cache_flush_group( $group );
|
||||
}
|
||||
// If wp_cache_flush_group is not available (older WP or no object cache),
|
||||
// individual entries will expire via TTL.
|
||||
$versions = get_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, array() );
|
||||
$versions = is_array( $versions ) ? $versions : array();
|
||||
$versions[ $post_type ] = (int) ( $versions[ $post_type ] ?? 0 ) + 1;
|
||||
update_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, $versions, true );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,7 +183,7 @@ class TMDO_Cache_Layer {
|
||||
return array(
|
||||
'groups' => $groups,
|
||||
'prefetch_support' => true,
|
||||
'flush_support' => function_exists( 'wp_cache_flush_group' ),
|
||||
'flush_support' => true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,57 +37,23 @@ class TMDO_Compatibility {
|
||||
}
|
||||
|
||||
self::$detected = array(
|
||||
'hpct_active' => self::is_hpct_active(),
|
||||
'hpct_imported' => self::is_hpct_imported(),
|
||||
'hivepress' => self::is_hivepress_active(),
|
||||
'woocommerce' => self::is_woocommerce_active(),
|
||||
'hivepress' => self::is_hivepress_active(),
|
||||
'woocommerce' => self::is_woocommerce_active(),
|
||||
);
|
||||
|
||||
return self::$detected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can WPDO register its HPCT-inherited module interceptors?
|
||||
*
|
||||
* True when:
|
||||
* - hp-custom-tables is NOT active, OR
|
||||
* - hp-custom-tables IS active but already imported into WPDO.
|
||||
*/
|
||||
public static function can_register_hpct_hooks(): bool {
|
||||
$compat = self::check();
|
||||
|
||||
// HPCT not active → WPDO can freely register.
|
||||
if ( ! $compat['hpct_active'] ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HPCT is active but already imported → WPDO takes over.
|
||||
if ( $compat['hpct_imported'] ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HPCT is active and NOT imported → defer to HPCT.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can WPDO register zone-specific interceptors (hot/warm/cold/archive)?
|
||||
*
|
||||
* Zone interceptors are always safe to register — they operate on
|
||||
* WPDO's own tables and do not conflict with HPCT.
|
||||
* WPDO's own tables.
|
||||
*/
|
||||
public static function can_register_zone_hooks(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should WPDO show an admin notice about HPCT import?
|
||||
*/
|
||||
public static function should_show_hpct_notice(): bool {
|
||||
$compat = self::check();
|
||||
return $compat['hpct_active'] && ! $compat['hpct_imported'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Is HivePress core active?
|
||||
*/
|
||||
@@ -102,6 +68,26 @@ class TMDO_Compatibility {
|
||||
return class_exists( 'WooCommerce' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrations that are active while their AddOn is not.
|
||||
*
|
||||
* Core does not know these plugins; their fields are only optimised by the
|
||||
* matching AddOn. The AddOn is detected by its bootstrap class, which its
|
||||
* main file declares as soon as the plugin loads.
|
||||
*
|
||||
* @return array<string, string> Plugin name → AddOn plugin slug.
|
||||
*/
|
||||
public static function missing_integration_addons(): array {
|
||||
$missing = array();
|
||||
if ( self::is_hivepress_active() && ! class_exists( 'TMDO_HP_Bootstrap' ) ) {
|
||||
$missing['HivePress'] = '2meet-data-optimizer-hivepress-addon';
|
||||
}
|
||||
if ( self::is_woocommerce_active() && ! class_exists( 'TMDO_Woocommerce_Bootstrap' ) ) {
|
||||
$missing['WooCommerce'] = '2meet-data-optimizer-woocommerce-addon';
|
||||
}
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is LatePoint plugin active? (v2.5.0 M16 polish — for module detector.)
|
||||
*
|
||||
@@ -113,23 +99,4 @@ class TMDO_Compatibility {
|
||||
|| defined( 'LATEPOINT_VERSION' )
|
||||
|| function_exists( 'latepoint_get_settings' );
|
||||
}
|
||||
|
||||
// ── Detection Methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Is HP Custom Tables plugin active (loaded)?
|
||||
*
|
||||
* Checks for the HPCT_Loader class, which is always loaded
|
||||
* when hp-custom-tables is active.
|
||||
*/
|
||||
public static function is_hpct_active(): bool {
|
||||
return class_exists( 'HPCT_Loader' ) || defined( 'HPCT_VERSION' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Has HPCT been imported into WPDO?
|
||||
*/
|
||||
private static function is_hpct_imported(): bool {
|
||||
return (bool) get_option( 'wpdo_hpct_imported', false );
|
||||
}
|
||||
}
|
||||
|
||||
+29
-287
@@ -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
|
||||
@@ -93,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 ) ) {
|
||||
@@ -111,17 +95,6 @@ class TMDO_Core {
|
||||
// Cache Layer hooks (Zone C prefetch + save_post warming).
|
||||
TMDO_Cache_Layer::register_hooks();
|
||||
|
||||
// Register HPCT-inherited module interceptors if allowed.
|
||||
if ( TMDO_Compatibility::can_register_hpct_hooks() ) {
|
||||
$this->register_hpct_interceptors();
|
||||
}
|
||||
|
||||
// v2.1.2 fix: WC commission interceptor is independent of HPCT — writes
|
||||
// to its own wp_wpdo_wc_commissions table. Register whenever WC is active.
|
||||
if ( class_exists( 'TMDO_WooCommerce' ) && TMDO_WooCommerce::is_active() && class_exists( 'TMDO_WC_Orders_Interceptor' ) ) {
|
||||
( new TMDO_WC_Orders_Interceptor() )->register_hooks();
|
||||
}
|
||||
|
||||
// Zone interceptors are always safe to register.
|
||||
if ( TMDO_Compatibility::can_register_zone_hooks() ) {
|
||||
$this->register_zone_hooks();
|
||||
@@ -136,6 +109,9 @@ class TMDO_Core {
|
||||
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
|
||||
TMDO_Hook_Bus_Bridge::maybe_init_hook_bus();
|
||||
|
||||
// Keep post_status / post_date copies on post entity tables current.
|
||||
TMDO_Post_Status_Sync::register();
|
||||
|
||||
// Register audit log listeners for wpdo_after_write / wpdo_after_delete.
|
||||
if ( class_exists( 'TMDO_Audit_Logger' ) ) {
|
||||
TMDO_Audit_Logger::init();
|
||||
@@ -149,18 +125,9 @@ 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();
|
||||
|
||||
// Admin notice for HPCT import.
|
||||
if ( is_admin() && TMDO_Compatibility::should_show_hpct_notice() ) {
|
||||
add_action( 'admin_notices', array( $this, 'render_hpct_notice' ) );
|
||||
}
|
||||
|
||||
// v2.6.6: async entity backfill cron handler.
|
||||
add_action( 'wpdo_entity_backfill_batch', array( $this, 'run_entity_backfill_batch' ), 10, 2 );
|
||||
|
||||
@@ -182,14 +149,6 @@ class TMDO_Core {
|
||||
add_action( TMDO_Comment_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_comment_stress_test_batch' ) );
|
||||
}
|
||||
|
||||
// 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,218 +241,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.
|
||||
*/
|
||||
private function register_hpct_interceptors(): void {
|
||||
// 7 HPCT-era meta interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
|
||||
// 1 LatePoint interceptor 已搬到 2meet-data-optimizer-latepoint-addon。
|
||||
// 5 query interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
|
||||
// 此方法保留為空殼以保持 ABI 相容;AddOn 各自於自身 bootstrap 內 register_hooks。
|
||||
}
|
||||
|
||||
/**
|
||||
* Register zone-specific hooks.
|
||||
*/
|
||||
@@ -514,6 +261,9 @@ class TMDO_Core {
|
||||
$post_router = new TMDO_Post_Query_Router();
|
||||
$post_router->register_hooks();
|
||||
}
|
||||
if ( class_exists( 'TMDO_Post_Query_Router' ) ) {
|
||||
add_action( 'clean_post_cache', array( 'TMDO_Post_Query_Router', 'bump_count_version' ) );
|
||||
}
|
||||
|
||||
// Zone B: Warm cleanup cron handler.
|
||||
add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) );
|
||||
@@ -549,6 +299,19 @@ class TMDO_Core {
|
||||
// v2.3.0 M6: daily health probe cron.
|
||||
add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) );
|
||||
|
||||
// 1.2.0: daily flat-vs-native drift sample (read by the post query router).
|
||||
add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );
|
||||
|
||||
// 1.2.0: rebuild flat from native after writes bypassed it (entity was
|
||||
// disabled, or the Hook Bus was off).
|
||||
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );
|
||||
add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );
|
||||
add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );
|
||||
add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );
|
||||
|
||||
// 1.3.0: declared indexes a visitor request left for WP-Cron.
|
||||
add_action( TMDO_Schema_Manager::INDEXES_HOOK, array( 'TMDO_Schema_Manager', 'ensure_pending_indexes' ) );
|
||||
|
||||
// v2.3.0 M6: daily snapshot prune cron (paired with health check).
|
||||
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
|
||||
|
||||
@@ -791,27 +554,6 @@ class TMDO_Core {
|
||||
TMDO_Zone_Archive::sweep();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render admin notice when hp-custom-tables is detected but not imported.
|
||||
*/
|
||||
public function render_hpct_notice(): void {
|
||||
$import_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=hpct-import' );
|
||||
?>
|
||||
<div class="notice notice-warning is-dismissible">
|
||||
<p>
|
||||
<strong>WP Data Optimizer:</strong>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: URL to import page */
|
||||
esc_html__( 'HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。', '2meet-data-optimizer' ),
|
||||
'<a href="' . esc_url( $import_url ) . '">' . esc_html__( 'HPCT 匯入頁面', '2meet-data-optimizer' ) . '</a>'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* WP Cron handler: process one backfill batch for an entity/group pair.
|
||||
*
|
||||
|
||||
@@ -27,17 +27,9 @@ class TMDO_Feature_Flags {
|
||||
private const OPTION_KEY = 'wpdo_features';
|
||||
|
||||
/**
|
||||
* All known HPCT-inherited modules.
|
||||
* These modules operate on existing hpct_* tables.
|
||||
* Modules driven by AddOn interceptors (WooCommerce commissions, LatePoint).
|
||||
*/
|
||||
public const HPCT_MODULES = array(
|
||||
'reviews',
|
||||
'messages',
|
||||
'favorites',
|
||||
'memberships',
|
||||
'statistics',
|
||||
'requests',
|
||||
'listing_meta',
|
||||
public const ADDON_MODULES = array(
|
||||
'wc_orders',
|
||||
'latepoint',
|
||||
);
|
||||
@@ -300,7 +292,7 @@ class TMDO_Feature_Flags {
|
||||
$saved = array();
|
||||
}
|
||||
|
||||
$all_modules = array_merge( self::HPCT_MODULES, self::ZONE_MODULES );
|
||||
$all_modules = array_merge( self::ADDON_MODULES, self::ZONE_MODULES );
|
||||
$defaults = array_fill_keys( $all_modules, 'idle' );
|
||||
|
||||
self::$cache = array_merge( $defaults, $saved );
|
||||
@@ -308,13 +300,13 @@ class TMDO_Feature_Flags {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only HPCT-inherited module states.
|
||||
* Return only AddOn module states.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function hpct_modules(): array {
|
||||
public static function addon_modules(): array {
|
||||
$all = self::all();
|
||||
return array_intersect_key( $all, array_flip( self::HPCT_MODULES ) );
|
||||
return array_intersect_key( $all, array_flip( self::ADDON_MODULES ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,23 +318,4 @@ class TMDO_Feature_Flags {
|
||||
$all = self::all();
|
||||
return array_intersect_key( $all, array_flip( self::ZONE_MODULES ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an HPCT feature flag status to a WPDO state.
|
||||
*
|
||||
* Used during HPCT import to translate HPCT's 4-state model
|
||||
* to WPDO's 7-state model.
|
||||
*
|
||||
* @param string $hpct_status HPCT status (disabled/migrating/verified/enabled).
|
||||
* @return string WPDO state.
|
||||
*/
|
||||
public static function map_hpct_status( string $hpct_status ): string {
|
||||
return match ( $hpct_status ) {
|
||||
'disabled' => 'idle',
|
||||
'migrating' => 'backfill',
|
||||
'verified' => 'cutover',
|
||||
'enabled' => 'complete',
|
||||
default => 'idle',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ final class TMDO_Hook_Bus_Bridge {
|
||||
if ( null !== self::$cache ) {
|
||||
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;
|
||||
}
|
||||
@@ -98,13 +98,6 @@ final class TMDO_Hook_Bus_Bridge {
|
||||
* @var string[]
|
||||
*/
|
||||
private const COEXIST_WHITELIST = array(
|
||||
'TMDO_Reviews_Interceptor',
|
||||
'TMDO_Messages_Interceptor',
|
||||
'TMDO_Favorites_Interceptor',
|
||||
'TMDO_Memberships_Interceptor',
|
||||
'TMDO_Statistics_Interceptor',
|
||||
'TMDO_Requests_Interceptor',
|
||||
'TMDO_Listing_Meta_Interceptor',
|
||||
'TMDO_WC_Orders_Interceptor',
|
||||
'TMDO_LatePoint_Interceptor',
|
||||
'TMDO_Sync_Bridge',
|
||||
|
||||
@@ -40,6 +40,9 @@ class TMDO_Installer {
|
||||
*/
|
||||
private const SCHEMA_VERSION = '2.1.0';
|
||||
|
||||
/** Set once maybe_preserve_misc_bucket_default() has run for this site. */
|
||||
public const MISC_BUCKET_DEFAULT_FLAG = 'wpdo_misc_bucket_default_v2';
|
||||
|
||||
/** Allowed SQL base types for hot-zone column definitions. */
|
||||
private const ALLOWED_COL_BASE_TYPES = array(
|
||||
'bigint',
|
||||
@@ -221,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).
|
||||
*
|
||||
@@ -299,6 +267,128 @@ class TMDO_Installer {
|
||||
if ( get_option( 'wpdo_db_version' ) !== self::SCHEMA_VERSION ) {
|
||||
self::install();
|
||||
}
|
||||
self::maybe_preserve_misc_bucket_default();
|
||||
self::maybe_init_post_scoped_routing();
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time default for post_type-scoped entity routing.
|
||||
*
|
||||
* Autoloaded because TMDO_Entity_Registry::get_field() reads it per lookup.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_init_post_scoped_routing(): void {
|
||||
global $wpdb;
|
||||
if ( false !== get_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, false ) || ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$has_rows = false;
|
||||
$tables = (array) $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%' ) );
|
||||
foreach ( $tables as $table ) {
|
||||
if ( $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from SHOW TABLES.
|
||||
$has_rows = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
add_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, self::post_scoped_routing_default( TMDO_Mode_Manager::get( 'post' ), $has_rows ), '', true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether post_type-scoped routing can start on right away.
|
||||
*
|
||||
* Existing post flat rows were written with legacy routing (a key shared by
|
||||
* several groups went to the last one registered), so they may sit in
|
||||
* another post type's table. Scoped lookups stay off there until the rows
|
||||
* are reconciled; with the post bridge disabled nothing reads them.
|
||||
*
|
||||
* @param string $post_mode Current post entity bridge mode.
|
||||
* @param bool $has_post_flat_rows Whether any wpdo_post_* table has rows.
|
||||
* @return string '1' to enable, '0' to wait for reconcile.
|
||||
*/
|
||||
public static function post_scoped_routing_default( string $post_mode, bool $has_post_flat_rows ): string {
|
||||
return ( 'disabled' === $post_mode || ! $has_post_flat_rows ) ? '1' : '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration for the misc bucket default flip (on → off).
|
||||
*
|
||||
* The bucket used to default to enabled; its rows are only readable while it
|
||||
* is on. Sites that already hold misc rows and never set the toggle get '1'
|
||||
* persisted so turning the default off cannot hide their data. Runs from
|
||||
* maybe_upgrade() rather than install() because install() only runs when
|
||||
* SCHEMA_VERSION changes; the flag is autoloaded since it is read per request.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_preserve_misc_bucket_default(): void {
|
||||
if ( get_option( self::MISC_BUCKET_DEFAULT_FLAG ) || ! class_exists( 'TMDO_Term_Comment_Misc_Bucket' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$unset = false === get_option( TMDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
|
||||
$has_rows = TMDO_Term_Comment_Misc_Bucket::count_rows( 'term' ) > 0
|
||||
|| TMDO_Term_Comment_Misc_Bucket::count_rows( 'comment' ) > 0;
|
||||
|
||||
if ( $unset && $has_rows ) {
|
||||
update_option( TMDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '1', false );
|
||||
}
|
||||
|
||||
update_option( self::MISC_BUCKET_DEFAULT_FLAG, '1', true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasons this site's wpdo_* tables cannot be dropped yet; empty means safe.
|
||||
*
|
||||
* Reads the site's own options rather than Mode_Manager / Feature_Flags,
|
||||
* whose per-request caches do not follow switch_to_blog() during uninstall.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function uninstall_blockers(): array {
|
||||
global $wpdb;
|
||||
$blockers = array();
|
||||
|
||||
$modes = get_option( 'wpdo_bridge_modes', array() );
|
||||
foreach ( is_array( $modes ) ? $modes : array() as $entity => $mode ) {
|
||||
if ( 'aeav_only' === $mode ) {
|
||||
$blockers[] = sprintf( '%1$s 的 bridge 模式是 aeav_only:資料只存在 wpdo_%1$s_* 扁平表。', $entity );
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( array( 'wpdo_term_misc', 'wpdo_comment_misc' ) as $name ) {
|
||||
if ( self::table_has_rows( $wpdb->prefix . $name ) ) {
|
||||
$blockers[] = sprintf( '%s 有資料:這些 term / comment meta 只存在 misc 表。', $name );
|
||||
}
|
||||
}
|
||||
|
||||
$features = get_option( 'wpdo_features', array() );
|
||||
foreach ( TMDO_Feature_Flags::ZONE_MODULES as $module ) {
|
||||
$state = is_array( $features ) ? ( $features[ $module ] ?? 'idle' ) : 'idle';
|
||||
if ( in_array( $state, array( 'cleanup', 'complete' ), true ) ) {
|
||||
$blockers[] = sprintf( '區域模組 %1$s 處於 %2$s:讀取已改走 wpdo_* 表。', $module, $state );
|
||||
}
|
||||
}
|
||||
|
||||
return $blockers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a table exists and holds at least one row.
|
||||
*
|
||||
* @param string $table Full table name.
|
||||
* @return bool
|
||||
*/
|
||||
private static function table_has_rows( string $table ): bool {
|
||||
global $wpdb;
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ) {
|
||||
return false;
|
||||
}
|
||||
return null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" );
|
||||
// phpcs:enable
|
||||
}
|
||||
|
||||
// ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ─────────────────
|
||||
@@ -698,6 +788,8 @@ class TMDO_Installer {
|
||||
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
|
||||
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
|
||||
wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
|
||||
wp_clear_scheduled_hook( 'tmdo_drift_resync' );
|
||||
wp_clear_scheduled_hook( 'tmdo_ensure_group_indexes' );
|
||||
wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
|
||||
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
|
||||
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
|
||||
@@ -753,18 +845,11 @@ class TMDO_Installer {
|
||||
$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',
|
||||
);
|
||||
@@ -908,6 +993,8 @@ class TMDO_Installer {
|
||||
'wpdo_health_snapshot_monthly',
|
||||
'wpdo_post_shadow_verify',
|
||||
'wpdo_term_comment_shadow_verify',
|
||||
'tmdo_drift_resync',
|
||||
'tmdo_ensure_group_indexes',
|
||||
'wpdo_post_stress_test_batch',
|
||||
'wpdo_user_stress_test_batch',
|
||||
'wpdo_term_stress_test_batch',
|
||||
@@ -1272,7 +1359,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;
|
||||
|
||||
@@ -48,7 +48,7 @@ class TMDO_Logger {
|
||||
/**
|
||||
* Log an INFO-level event (event-based signature, used by v2.0.0 engine code).
|
||||
*
|
||||
* Writes only to PHP error_log; does NOT touch wpdo_errors (which is reserved
|
||||
* Writes only to PHP error_log, and only when debug logging is on; does NOT touch wpdo_errors (which is reserved
|
||||
* for actual errors). Mode changes / cache flushes / audit prunes call here
|
||||
* many times per request — keeping them out of the DB error table.
|
||||
*
|
||||
@@ -57,19 +57,36 @@ class TMDO_Logger {
|
||||
* @return void
|
||||
*/
|
||||
public static function info( string $event, array $context = array() ): void {
|
||||
// Routine events: only with WP_DEBUG_LOG (filter `tmdo_log_info`), or every
|
||||
// CLI run and production request would flood the log.
|
||||
if ( ! apply_filters( 'tmdo_log_info', defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) ) {
|
||||
return;
|
||||
}
|
||||
self::write_info( $event, $context );
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an INFO line to the PHP error log.
|
||||
*
|
||||
* @param string $event Event name.
|
||||
* @param array<string, mixed> $context Structured context.
|
||||
* @return void
|
||||
*/
|
||||
private static function write_info( string $event, array $context ): void {
|
||||
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a NOTICE-level event — alias of info() for callers that want a stronger
|
||||
* level intent. Same routing (error_log only).
|
||||
* Log a NOTICE-level event: always written to the PHP error log (unlike
|
||||
* info()), for the audit trail of operator actions such as mode changes
|
||||
* and snapshot deletions.
|
||||
*
|
||||
* @param string $event Event name.
|
||||
* @param array $context Structured context.
|
||||
* @param string $event Event name.
|
||||
* @param array<string, mixed> $context Structured context.
|
||||
* @return void
|
||||
*/
|
||||
public static function notice( string $event, array $context = array() ): void {
|
||||
self::info( $event, $context );
|
||||
error_log( sprintf( '[WPDO][NOTICE][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +114,7 @@ class TMDO_Logger {
|
||||
*/
|
||||
public static function debug( string $event, array $context = array() ): void {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
self::info( $event, $context );
|
||||
self::write_info( $event, $context );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ final class TMDO_Post_Shadow_Verifier {
|
||||
* is available.
|
||||
*
|
||||
* @param string $post_type WP post_type to sample.
|
||||
* @param string $group_name Entity group name (e.g. 'wc_product').
|
||||
* @param string $group_name Entity group name (e.g. 'hp_listing_core').
|
||||
* @param string $flat_table Fully qualified flat table name.
|
||||
* @param string[] $keys Meta keys to compare (each post checks all).
|
||||
* @param int $sample_size Number of posts to sample (capped to DB count).
|
||||
@@ -303,23 +303,7 @@ final class TMDO_Post_Shadow_Verifier {
|
||||
* @return string|null
|
||||
*/
|
||||
private static function group_post_type( string $group ): ?string {
|
||||
switch ( $group ) {
|
||||
case 'attachment':
|
||||
return 'attachment';
|
||||
case 'wc_product':
|
||||
return 'product';
|
||||
case 'hp_listing_core':
|
||||
return 'hp_listing';
|
||||
case 'hp_request_core':
|
||||
return 'hp_request';
|
||||
case 'hp_vendor_core':
|
||||
return 'hp_vendor';
|
||||
case 'nav_menu_item':
|
||||
return 'nav_menu_item';
|
||||
case 'wp_core':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return TMDO_Post_Fields::post_type_for_group( $group );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,9 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
*/
|
||||
class TMDO_REST_API {
|
||||
|
||||
/** Blocked views are counted 1 in this many (wpdo_rl_stats). */
|
||||
private const RL_STATS_SAMPLE = 10;
|
||||
|
||||
const NAMESPACE = 'wpdo/v1';
|
||||
|
||||
/**
|
||||
@@ -37,6 +40,8 @@ class TMDO_REST_API {
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_listings' ),
|
||||
// Public by design: viewable post types and published posts only, fields
|
||||
// declared show_in_rest only (1.1.1), per_page capped (wpdo_rest_max_per_page).
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => $this->listings_args(),
|
||||
)
|
||||
@@ -48,6 +53,8 @@ class TMDO_REST_API {
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_listing' ),
|
||||
// Public by design: the post must be viewable to the request (assert_post_viewable_or_error:
|
||||
// publicly viewable or read_post); fields declared show_in_rest only (1.1.1).
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
@@ -65,6 +72,7 @@ class TMDO_REST_API {
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_stats' ),
|
||||
// Public by design: view / engagement counters of a post viewable to the request.
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
@@ -82,6 +90,8 @@ class TMDO_REST_API {
|
||||
array(
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'post_view' ),
|
||||
// Public by design: anonymous visitors count views; viewable posts only, one view
|
||||
// per IP per post per hour (rate limited).
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => array(
|
||||
'id' => array(
|
||||
@@ -529,7 +539,16 @@ class TMDO_REST_API {
|
||||
* @return WP_REST_Response REST response.
|
||||
*/
|
||||
public function get_listings( WP_REST_Request $request ): WP_REST_Response {
|
||||
$post_type = $request->get_param( 'post_type' ) ?? 'hp_listing';
|
||||
$post_type = $request->get_param( 'post_type' ) ?? self::default_post_type();
|
||||
if ( ! post_type_exists( $post_type ) || ! is_post_type_viewable( $post_type ) ) {
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'code' => 'rest_invalid_post_type',
|
||||
'message' => 'Invalid post type.',
|
||||
),
|
||||
400
|
||||
);
|
||||
}
|
||||
// Defense-in-depth: clamp per_page even if validate_callback in listings_args() fails open.
|
||||
// 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 );
|
||||
@@ -709,14 +728,18 @@ class TMDO_REST_API {
|
||||
|
||||
if ( $cookie_blocked || $ip_blocked ) {
|
||||
// Track rate-limit hits per post for admin visibility, capped at 100 entries.
|
||||
$rl_stats = get_option( 'wpdo_rl_stats', array() );
|
||||
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1;
|
||||
if ( count( $rl_stats ) > 100 ) {
|
||||
// Keep only the top-100 posts by hit count to bound option size.
|
||||
arsort( $rl_stats );
|
||||
$rl_stats = array_slice( $rl_stats, 0, 100, true );
|
||||
// Sampled (1 in RL_STATS_SAMPLE, each sample adding RL_STATS_SAMPLE) so
|
||||
// anonymous requests cannot turn every blocked view into an option write.
|
||||
if ( 1 === wp_rand( 1, self::RL_STATS_SAMPLE ) ) {
|
||||
$rl_stats = get_option( 'wpdo_rl_stats', array() );
|
||||
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + self::RL_STATS_SAMPLE;
|
||||
if ( count( $rl_stats ) > 100 ) {
|
||||
// Keep only the top-100 posts by hit count to bound option size.
|
||||
arsort( $rl_stats );
|
||||
$rl_stats = array_slice( $rl_stats, 0, 100, true );
|
||||
}
|
||||
update_option( 'wpdo_rl_stats', $rl_stats, false );
|
||||
}
|
||||
update_option( 'wpdo_rl_stats', $rl_stats, false );
|
||||
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
@@ -1365,6 +1388,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.
|
||||
@@ -1383,8 +1410,13 @@ class TMDO_REST_API {
|
||||
): WP_REST_Response {
|
||||
global $wpdb;
|
||||
|
||||
// Filters and ordering only on public columns: hidden values must not be
|
||||
// inferable by filtering or sorting on them.
|
||||
$table = TMDO_Zone_Hot::table( $post_type );
|
||||
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
|
||||
$columns = array_intersect_key(
|
||||
TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ),
|
||||
array_flip( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) )
|
||||
);
|
||||
|
||||
// Build WHERE clauses from numeric filter params.
|
||||
$where = array();
|
||||
@@ -1396,6 +1428,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 +1438,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
|
||||
@@ -1432,8 +1462,8 @@ class TMDO_REST_API {
|
||||
'post_type' => $post_type,
|
||||
);
|
||||
unset( $row['post_id'], $row['updated_at'] );
|
||||
// Keep only columns with show_in_rest => true (or all if no registry entry).
|
||||
$row = $visible_cols ? array_intersect_key( $row, $visible_cols ) : $row;
|
||||
// Keep only public columns (none when nothing is declared public).
|
||||
$row = array_intersect_key( $row, $visible_cols );
|
||||
return array_merge( $item, $row );
|
||||
},
|
||||
$rows
|
||||
@@ -1476,8 +1506,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 +1537,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 +1545,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 +1554,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,
|
||||
);
|
||||
}
|
||||
@@ -1539,13 +1569,25 @@ class TMDO_REST_API {
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post type GET /listings serves when the request names none.
|
||||
*
|
||||
* Filter `tmdo_rest_default_post_type` lets an integration (e.g. the
|
||||
* HivePress AddOn) make its own post type the default.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function default_post_type(): string {
|
||||
return (string) apply_filters( 'tmdo_rest_default_post_type', 'post' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Argument definitions for GET /listings.
|
||||
*/
|
||||
private function listings_args(): array {
|
||||
return array(
|
||||
'post_type' => array(
|
||||
'default' => 'hp_listing',
|
||||
'default' => self::default_post_type(),
|
||||
'sanitize_callback' => 'sanitize_key',
|
||||
),
|
||||
'per_page' => array(
|
||||
|
||||
@@ -67,6 +67,13 @@ class TMDO_Schema_Registry {
|
||||
*/
|
||||
private array $warm_fields = array();
|
||||
|
||||
/**
|
||||
* Providers already told the Hot zone is frozen (once per request).
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
private array $hot_deprecation_flagged = array();
|
||||
|
||||
/**
|
||||
* Private constructor — use instance() to get the singleton.
|
||||
*/
|
||||
@@ -96,7 +103,9 @@ class TMDO_Schema_Registry {
|
||||
* - ttl (int) TTL in seconds (Zone B only, null = no expiry).
|
||||
* - cache_group (string) Object cache group (Zone C only).
|
||||
* - cache_ttl (int) Cache TTL in seconds (Zone C only).
|
||||
* - show_in_rest (bool) Whether to expose via GET /wpdo/v1/listings. Default true.
|
||||
* - show_in_rest (bool) Whether to expose via the public GET /wpdo/v1/listings
|
||||
* endpoints. Default false; protected (underscore) keys and
|
||||
* credential-like keys are never exposed (see rest_exposable()).
|
||||
*/
|
||||
public function register( string $provider, array $config ): void {
|
||||
$config = wp_parse_args(
|
||||
@@ -113,7 +122,7 @@ class TMDO_Schema_Registry {
|
||||
'cache_group' => '',
|
||||
'cache_ttl' => HOUR_IN_SECONDS,
|
||||
'provider' => $provider,
|
||||
'show_in_rest' => true,
|
||||
'show_in_rest' => false,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -138,6 +147,7 @@ class TMDO_Schema_Registry {
|
||||
switch ( $config['zone'] ) {
|
||||
case 'hot':
|
||||
$this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type'];
|
||||
$this->flag_hot_deprecation( $provider, $config['meta_key'] );
|
||||
break;
|
||||
|
||||
case 'warm':
|
||||
@@ -159,6 +169,29 @@ class TMDO_Schema_Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hot zone is frozen (ADR-003): fields still register so existing hot
|
||||
* data stays readable, but each provider gets one notice per request.
|
||||
*
|
||||
* @param string $provider Provider name.
|
||||
* @param string $meta_key First hot meta_key seen from it.
|
||||
*/
|
||||
private function flag_hot_deprecation( string $provider, string $meta_key ): void {
|
||||
if ( isset( $this->hot_deprecation_flagged[ $provider ] ) ) {
|
||||
return;
|
||||
}
|
||||
$this->hot_deprecation_flagged[ $provider ] = true;
|
||||
_doing_it_wrong(
|
||||
__CLASS__ . '::register',
|
||||
sprintf(
|
||||
'Provider "%1$s" registered Hot zone field "%2$s". The Hot zone is frozen; register an entity group (TMDO_Entity_Registry::register_group) instead.',
|
||||
esc_html( $provider ),
|
||||
esc_html( $meta_key )
|
||||
),
|
||||
esc_html( TMDO_VERSION )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk register multiple fields.
|
||||
*
|
||||
@@ -245,6 +278,25 @@ class TMDO_Schema_Registry {
|
||||
return array_unique( $this->cold_fields[ $post_type ] ?? array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a field may appear in the public listings endpoints: declared
|
||||
* show_in_rest, not a protected meta key (leading underscore — WordPress
|
||||
* hides those from public output, e.g. _purchase_note), and not named like a
|
||||
* credential (tokens, secrets, passwords, API keys) or a contact / location
|
||||
* detail (email, phone, address, IP), whatever it declares.
|
||||
*
|
||||
* @param array<string, mixed> $field Registered field.
|
||||
* @return bool
|
||||
*/
|
||||
private static function rest_exposable( array $field ): bool {
|
||||
$key = (string) ( $field['meta_key'] ?? '' );
|
||||
return ! empty( $field['show_in_rest'] )
|
||||
&& 0 !== strpos( $key, '_' )
|
||||
&& 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) )
|
||||
// Contact and location details (2.3 PR-096): private even when declared public.
|
||||
&& 1 !== preg_match( '/e_?mail|phone|address|(^|_)ip(_|$|\s)/i', $key . ' ' . ( $field['column'] ?? '' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns hot column names for a post type where show_in_rest is true.
|
||||
* Used by REST endpoints to restrict publicly exposed fields.
|
||||
@@ -255,7 +307,7 @@ class TMDO_Schema_Registry {
|
||||
public function get_rest_visible_hot_columns( string $post_type ): array {
|
||||
$visible = array();
|
||||
foreach ( $this->fields as $field ) {
|
||||
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
|
||||
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
|
||||
$visible[] = $field['column'];
|
||||
}
|
||||
}
|
||||
@@ -272,7 +324,7 @@ class TMDO_Schema_Registry {
|
||||
public function get_rest_visible_cold_keys( string $post_type ): array {
|
||||
$visible = array();
|
||||
foreach ( $this->fields as $field ) {
|
||||
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
|
||||
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
|
||||
$visible[] = $field['meta_key'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,20 +60,22 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
final class TMDO_Term_Comment_Backfill {
|
||||
|
||||
/**
|
||||
* Group → flat table suffix mapping. Mirror of TMDO_Hivepress_Term_Comment_Fields.
|
||||
* Term and comment groups registered in the entity registry.
|
||||
*
|
||||
* @var array<string, array{entity_type:string, table_suffix:string}>
|
||||
* @return array<string, string> Group name → entity type ('term' | 'comment').
|
||||
*/
|
||||
private const GROUP_MAP = array(
|
||||
'hp_taxonomy' => array(
|
||||
'entity_type' => 'term',
|
||||
'table_suffix' => 'wpdo_term_hp_taxonomy',
|
||||
),
|
||||
'hp_review' => array(
|
||||
'entity_type' => 'comment',
|
||||
'table_suffix' => 'wpdo_comment_hp_review',
|
||||
),
|
||||
);
|
||||
public static function registered_groups(): array {
|
||||
$groups = array();
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return $groups;
|
||||
}
|
||||
foreach ( array( 'term', 'comment' ) as $entity_type ) {
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
|
||||
$groups[ $group ] = $groups[ $group ] ?? $entity_type;
|
||||
}
|
||||
}
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run backfill for every registered term + comment group.
|
||||
@@ -83,8 +85,8 @@ final class TMDO_Term_Comment_Backfill {
|
||||
*/
|
||||
public static function backfill_all( bool $dry_run = false ): array {
|
||||
$results = array();
|
||||
foreach ( self::GROUP_MAP as $group_name => $cfg ) {
|
||||
$results[ $group_name ] = self::backfill_group( $cfg['entity_type'], $group_name, $dry_run );
|
||||
foreach ( self::registered_groups() as $group_name => $entity_type ) {
|
||||
$results[ $group_name ] = self::backfill_group( $entity_type, $group_name, $dry_run );
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
@@ -110,7 +112,7 @@ final class TMDO_Term_Comment_Backfill {
|
||||
$result['error'] = 'Entity Registry unavailable';
|
||||
return $result;
|
||||
}
|
||||
if ( ! isset( self::GROUP_MAP[ $group_name ] ) ) {
|
||||
if ( ( self::registered_groups()[ $group_name ] ?? null ) !== $entity_type ) {
|
||||
$result['error'] = "Unknown group: {$group_name}";
|
||||
return $result;
|
||||
}
|
||||
@@ -140,7 +142,7 @@ final class TMDO_Term_Comment_Backfill {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$flat_table = $wpdb->prefix . self::GROUP_MAP[ $group_name ]['table_suffix'];
|
||||
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
|
||||
|
||||
// Count candidate entities — those with at least one of the registered keys in wp_*meta.
|
||||
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
||||
|
||||
@@ -41,23 +41,6 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
||||
/** Default sample size per entity per cron tick. */
|
||||
public const DEFAULT_SAMPLE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Group → flat table suffix mapping. Keep in sync with
|
||||
* TMDO_Hivepress_Term_Comment_Fields registrations and the misc bucket.
|
||||
*
|
||||
* @var array<string, array{entity_type:string, table_suffix:string, has_misc:bool}>
|
||||
*/
|
||||
private const GROUP_MAP = array(
|
||||
'hp_taxonomy' => array(
|
||||
'entity_type' => 'term',
|
||||
'table_suffix' => 'wpdo_term_hp_taxonomy',
|
||||
),
|
||||
'hp_review' => array(
|
||||
'entity_type' => 'comment',
|
||||
'table_suffix' => 'wpdo_comment_hp_review',
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Run a sample-compare pass for a single entity group.
|
||||
*
|
||||
@@ -215,11 +198,7 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
foreach ( self::GROUP_MAP as $group => $cfg ) {
|
||||
$entity_type = $cfg['entity_type'];
|
||||
|
||||
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $entity_type ) {
|
||||
// Only run when the entity is in shadow_read mode.
|
||||
if ( 'term' === $entity_type && ! $term_shadow ) {
|
||||
continue;
|
||||
@@ -232,7 +211,7 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
||||
if ( empty( $keys ) ) {
|
||||
continue;
|
||||
}
|
||||
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
|
||||
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||
|
||||
try {
|
||||
self::sample_compare( $entity_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
|
||||
@@ -337,16 +316,14 @@ final class TMDO_Term_Comment_Shadow_Verifier {
|
||||
return array();
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$out = array();
|
||||
|
||||
foreach ( self::GROUP_MAP as $group => $cfg ) {
|
||||
$entity_type = $cfg['entity_type'];
|
||||
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
|
||||
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $entity_type ) {
|
||||
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
|
||||
if ( empty( $keys ) ) {
|
||||
continue;
|
||||
}
|
||||
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
|
||||
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||
|
||||
try {
|
||||
$out[ $group ] = self::sample_compare( $entity_type, $group, $flat_table, $keys, $sample_size );
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Drift_Check — read-only flat-vs-native comparison for every entity type.
|
||||
*
|
||||
* For each backfillable group and key, the flat column is compared with what
|
||||
* native meta would put there (first native row → safe unserialize →
|
||||
* TMDO_Type_Caster::to_db()):
|
||||
*
|
||||
* miss — native holds a non-empty value, the flat column is NULL or has no row
|
||||
* diff — both hold a value and the values differ (numbers, JSON and
|
||||
* serialized values are compared by value, not by formatting)
|
||||
* extra — the flat column holds a value, native has no row for the key
|
||||
* (a required column holding its default counts as absent); rows of
|
||||
* another post type or of a deleted entity count too
|
||||
*
|
||||
* run() writes nothing except the per-group outcome in the wpdo_drift_status
|
||||
* option. fix() rebuilds the drifted rows from native (native is never written).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
// phpcs:ignore WPDO.AntiEAV -- drift checker: must read raw meta to compare it with the flat tables
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Read-only checker: table and column names come from the registry / adapters, meta keys and ids go through prepare() or intval.
|
||||
|
||||
/**
|
||||
* Flat-vs-native drift checker.
|
||||
*/
|
||||
final class TMDO_Drift_Check {
|
||||
|
||||
/**
|
||||
* Option holding the last outcome per entity / group.
|
||||
*/
|
||||
public const OPTION = 'wpdo_drift_status';
|
||||
|
||||
/**
|
||||
* Entities per group sampled by the daily check.
|
||||
*/
|
||||
public const CRON_SAMPLE = 500;
|
||||
|
||||
/**
|
||||
* Single WP-Cron event rebuilding an entity's flat tables from native after
|
||||
* writes bypassed them. Argument: the entity type.
|
||||
*/
|
||||
public const RESYNC_HOOK = 'tmdo_drift_resync';
|
||||
|
||||
/**
|
||||
* Seconds before the scheduled rebuild, so the switch that triggered it
|
||||
* finishes first.
|
||||
*/
|
||||
private const RESYNC_DELAY = 60;
|
||||
|
||||
/**
|
||||
* MySQL regex for a plain decimal number, used to skip rows that obviously match.
|
||||
* Exact matches compare BINARY: meta and flat tables may differ in collation,
|
||||
* and a case-insensitive one would hide 'b' vs 'B'.
|
||||
*/
|
||||
private const SQL_NUMERIC = '^-?[0-9]+(\\\\.[0-9]+)?$';
|
||||
|
||||
/**
|
||||
* Compare one entity type's flat tables with native meta.
|
||||
*
|
||||
* @param string $entity_type post | user | term | comment.
|
||||
* @param string $group One group, or '' for every backfillable group.
|
||||
* @param int $sample 0 checks every entity; N checks N random entities per group.
|
||||
* @return array{entity:string, sample:int, groups:array<string, array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>}>, total:array{miss:int, diff:int, extra:int}}
|
||||
* @throws InvalidArgumentException Unknown entity type or group.
|
||||
*/
|
||||
public static function run( string $entity_type, string $group = '', int $sample = 0 ): array {
|
||||
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
|
||||
|
||||
$report = array(
|
||||
'entity' => $entity_type,
|
||||
'sample' => $sample,
|
||||
'groups' => array(),
|
||||
'total' => array(
|
||||
'miss' => 0,
|
||||
'diff' => 0,
|
||||
'extra' => 0,
|
||||
),
|
||||
);
|
||||
$status = get_option( self::OPTION, array() );
|
||||
$status = is_array( $status ) ? $status : array();
|
||||
|
||||
foreach ( $groups as $name ) {
|
||||
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
continue;
|
||||
}
|
||||
$result = self::check_group( $adapter, $entity_type, $name, $table, $sample );
|
||||
unset( $result['ids'] );
|
||||
|
||||
$report['groups'][ $name ] = $result;
|
||||
foreach ( array( 'miss', 'diff', 'extra' ) as $kind ) {
|
||||
$report['total'][ $kind ] += $result[ $kind ];
|
||||
}
|
||||
$status[ $entity_type ][ $name ] = array(
|
||||
'miss' => $result['miss'],
|
||||
'diff' => $result['diff'],
|
||||
'extra' => $result['extra'],
|
||||
'sample' => $sample,
|
||||
'checked_at' => gmdate( 'Y-m-d H:i:s' ),
|
||||
);
|
||||
}
|
||||
|
||||
update_option( self::OPTION, $status );
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every backfillable group of the entity was checked within $max_age
|
||||
* seconds (sampled or full) and none drifted. No record counts as unclean.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param int $max_age Seconds.
|
||||
*/
|
||||
public static function clean_since( string $entity_type, int $max_age ): bool {
|
||||
$status = get_option( self::OPTION, array() );
|
||||
$status = is_array( $status ) ? ( $status[ $entity_type ] ?? array() ) : array();
|
||||
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
|
||||
if ( empty( $groups ) ) {
|
||||
return false;
|
||||
}
|
||||
foreach ( $groups as $group ) {
|
||||
$last = $status[ $group ] ?? null;
|
||||
if ( ! is_array( $last )
|
||||
|| (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0
|
||||
|| (int) strtotime( ( $last['checked_at'] ?? '' ) . ' UTC' ) < time() - $max_age ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily check (wpdo_daily_health_check): sample every entity type written
|
||||
* to both native and flat (dual_write, shadow_read). In aeav_only flat is
|
||||
* the source of truth and native is stale by design, so drift means nothing
|
||||
* there. The outcome replaces the recorded status, so a group found
|
||||
* drifting leaves the router at once.
|
||||
*/
|
||||
public static function cron_tick(): void {
|
||||
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||
if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) || ! TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
self::run( $entity_type, '', self::CRON_SAMPLE );
|
||||
} catch ( InvalidArgumentException $e ) {
|
||||
continue; // No adapter for this entity type on this site.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entities to rebuild after a mode change: one whose flat tables were not
|
||||
* written (disabled) is written next to native again, and values written
|
||||
* meanwhile are missing from flat.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $new_mode New mode.
|
||||
* @param string $old_mode Previous mode.
|
||||
* @return string[]
|
||||
*/
|
||||
public static function resync_after_mode_change( string $entity_type, string $new_mode, string $old_mode ): array {
|
||||
$keeps_native = array( TMDO_Mode_Manager::MODE_DUAL_WRITE, TMDO_Mode_Manager::MODE_SHADOW_READ );
|
||||
return TMDO_Mode_Manager::MODE_DISABLED === $old_mode && in_array( $new_mode, $keeps_native, true ) ? array( $entity_type ) : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Entities to rebuild after the Hook Bus flag changes: no write reached
|
||||
* flat while it was off. Every entity written to both native and flat
|
||||
* (aeav_only keeps flat as the source of truth).
|
||||
*
|
||||
* @param mixed $old_value Previous flag value.
|
||||
* @param mixed $value New flag value.
|
||||
* @return string[]
|
||||
*/
|
||||
public static function resync_after_hook_bus_change( $old_value, $value ): array {
|
||||
if ( self::flag_on( $old_value ) || ! self::flag_on( $value ) ) {
|
||||
return array();
|
||||
}
|
||||
$entities = array();
|
||||
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
|
||||
$entities[] = $entity_type;
|
||||
}
|
||||
}
|
||||
return $entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* `wpdo_bridge_mode_changed` handler.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $new_mode New mode.
|
||||
* @param string $old_mode Previous mode.
|
||||
*/
|
||||
public static function on_mode_changed( string $entity_type, string $new_mode, string $old_mode ): void {
|
||||
self::schedule_resync( self::resync_after_mode_change( $entity_type, $new_mode, $old_mode ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_option_wpdo_hook_bus_enabled` handler.
|
||||
*
|
||||
* @param mixed $old_value Previous flag value.
|
||||
* @param mixed $value New flag value.
|
||||
*/
|
||||
public static function on_hook_bus_toggled( $old_value, $value ): void {
|
||||
self::schedule_resync( self::resync_after_hook_bus_change( $old_value, $value ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* `add_option_wpdo_hook_bus_enabled` handler: the flag is stored for the first time.
|
||||
*
|
||||
* @param string $option Option name.
|
||||
* @param mixed $value Flag value.
|
||||
*/
|
||||
public static function on_hook_bus_added( $option, $value ): void {
|
||||
unset( $option );
|
||||
self::schedule_resync( self::resync_after_hook_bus_change( '0', $value ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* RESYNC_HOOK handler. An entity that has meanwhile moved to aeav_only is
|
||||
* left alone (fix() refuses).
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
*/
|
||||
public static function run_scheduled_fix( string $entity_type ): void {
|
||||
try {
|
||||
$result = self::fix( $entity_type );
|
||||
TMDO_Schema_Manager::resynced( $entity_type ); // Groups upgraded while deferred use the flat table again.
|
||||
TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] );
|
||||
} catch ( InvalidArgumentException | RuntimeException $e ) {
|
||||
TMDO_Logger::info(
|
||||
'drift_resync_skipped',
|
||||
array(
|
||||
'entity' => $entity_type,
|
||||
'reason' => $e->getMessage(),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule one rebuild per entity type (not twice while one is pending).
|
||||
*
|
||||
* @param string[] $entities Entity types.
|
||||
*/
|
||||
private static function schedule_resync( array $entities ): void {
|
||||
foreach ( $entities as $entity_type ) {
|
||||
if ( ! wp_next_scheduled( self::RESYNC_HOOK, array( $entity_type ) ) ) {
|
||||
wp_schedule_single_event( time() + self::RESYNC_DELAY, self::RESYNC_HOOK, array( $entity_type ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stored Hook Bus flag value means "on" (as TMDO_Hook_Bus_Bridge reads it).
|
||||
*
|
||||
* @param mixed $value Flag value.
|
||||
*/
|
||||
private static function flag_on( $value ): bool {
|
||||
return '1' === (string) $value || true === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild drifted flat rows from native meta, then check again.
|
||||
*
|
||||
* Rows of deleted entities (or, for post groups, of posts outside the
|
||||
* group's post types) are deleted. Every other entity with drift in any key
|
||||
* gets its whole row rebuilt from native; when native holds none of the
|
||||
* group's keys the row is kept with its columns cleared. Row caches of the
|
||||
* touched entities are invalidated. Native meta is never written.
|
||||
*
|
||||
* @param string $entity_type post | user | term | comment.
|
||||
* @param string $group One group, or '' for every backfillable group.
|
||||
* @return array{fixed:array{rebuilt:int, cleared:int, deleted_rows:int}, after:array<string, mixed>}
|
||||
* @throws RuntimeException In aeav_only, where flat is the source of truth
|
||||
* (and InvalidArgumentException via resolve() for an
|
||||
* unknown entity type or group).
|
||||
*/
|
||||
public static function fix( string $entity_type, string $group = '' ): array {
|
||||
global $wpdb;
|
||||
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
|
||||
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ) {
|
||||
throw new RuntimeException( esc_html( $entity_type ) . ' is in aeav_only: flat is the source of truth and native meta is stale, so it cannot be rebuilt from native. Write flat back first (wp tmdo bridge-restore-native).' );
|
||||
}
|
||||
|
||||
$id = $adapter->get_entity_id_column();
|
||||
$fixed = array(
|
||||
'rebuilt' => 0,
|
||||
'cleared' => 0,
|
||||
'deleted_rows' => 0,
|
||||
);
|
||||
foreach ( $groups as $name ) {
|
||||
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ( self::stray_ids( $adapter, $entity_type, $name, $table ) as $eid ) {
|
||||
$fixed['deleted_rows'] += (int) $wpdb->delete( $table, array( $id => $eid ), array( '%d' ) );
|
||||
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
|
||||
}
|
||||
|
||||
foreach ( self::check_group( $adapter, $entity_type, $name, $table, 0 )['ids'] as $eid ) {
|
||||
if ( TMDO_Entity_Migration_Engine::migrate_entity( $entity_type, $name, $eid ) ) {
|
||||
++$fixed['rebuilt'];
|
||||
} else {
|
||||
self::clear_row( $entity_type, $name, $table, $id, $eid );
|
||||
++$fixed['cleared'];
|
||||
}
|
||||
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'fixed' => $fixed,
|
||||
'after' => self::run( $entity_type, $group ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter and group list for a run.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group One group, or '' for every backfillable group.
|
||||
* @return array{0:TMDO_Entity_Adapter_Interface, 1:string[]}
|
||||
* @throws InvalidArgumentException Unknown entity type or group.
|
||||
*/
|
||||
private static function resolve( string $entity_type, string $group ): array {
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
if ( ! $adapter ) {
|
||||
throw new InvalidArgumentException( 'Unknown entity type: ' . esc_html( $entity_type ) );
|
||||
}
|
||||
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
|
||||
if ( '' !== $group ) {
|
||||
if ( ! in_array( $group, $groups, true ) ) {
|
||||
throw new InvalidArgumentException( 'Unknown or orphan ' . esc_html( $entity_type ) . ' group: ' . esc_html( $group ) );
|
||||
}
|
||||
$groups = array( $group );
|
||||
}
|
||||
return array( $adapter, $groups );
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare every key of one group.
|
||||
*
|
||||
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group Group name.
|
||||
* @param string $table Group flat table.
|
||||
* @param int $sample 0, or the number of random entities.
|
||||
* @return array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>, ids:int[]}
|
||||
*/
|
||||
private static function check_group( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table, int $sample ): array {
|
||||
global $wpdb;
|
||||
|
||||
$meta = $adapter->get_native_meta_table();
|
||||
$id = $adapter->get_entity_id_column();
|
||||
$mid = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
|
||||
$scope = self::scope_sql( $adapter, $entity_type, $group, $sample );
|
||||
$result = array(
|
||||
'miss' => 0,
|
||||
'diff' => 0,
|
||||
'extra' => 0,
|
||||
'keys' => array(),
|
||||
'ids' => array(),
|
||||
);
|
||||
|
||||
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
|
||||
$key = (string) $field['key'];
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||
$type = $field['type'] ?? 'text';
|
||||
|
||||
// First native row per entity — what a single get_*_meta() read returns.
|
||||
$native = $wpdb->prepare( "SELECT m.`{$id}` AS eid, m.meta_value AS v FROM `{$meta}` m JOIN ( SELECT `{$id}`, MIN(`{$mid}`) AS mid FROM `{$meta}` WHERE meta_key = %s GROUP BY `{$id}` ) x ON x.mid = m.`{$mid}` WHERE 1=1", $key )
|
||||
. str_replace( '{id}', "m.`{$id}`", $scope );
|
||||
|
||||
// Text beyond the column width is stored as NULL by design (Type_Caster::fit_text).
|
||||
$fits = 'text' === $type ? ' AND CHAR_LENGTH(n.v) <= ' . TMDO_Type_Caster::TEXT_MAX_CHARS : '';
|
||||
$miss = array_map( 'intval', $wpdb->get_col( "SELECT n.eid FROM ( {$native} ) n LEFT JOIN `{$table}` f ON f.`{$id}` = n.eid WHERE LENGTH(n.v) > 0 AND f.`{$col}` IS NULL{$fits}" ) );
|
||||
|
||||
$candidates = $wpdb->get_results(
|
||||
"SELECT n.eid, n.v, f.`{$col}` AS flat FROM ( {$native} ) n JOIN `{$table}` f ON f.`{$id}` = n.eid
|
||||
WHERE f.`{$col}` IS NOT NULL AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY n.v
|
||||
OR ( n.v REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6)) ) )",
|
||||
ARRAY_A
|
||||
);
|
||||
$diff = array();
|
||||
foreach ( (array) $candidates as $row ) {
|
||||
if ( ! self::same( (string) $row['v'], (string) $row['flat'], $field ) ) {
|
||||
$diff[] = (int) $row['eid'];
|
||||
}
|
||||
}
|
||||
|
||||
$absent = $wpdb->prepare( "NOT EXISTS ( SELECT 1 FROM `{$meta}` m WHERE m.`{$id}` = f.`{$id}` AND m.meta_key = %s )", $key );
|
||||
if ( ! empty( $field['required'] ) ) {
|
||||
$default = (string) TMDO_Type_Caster::to_db( null, $field );
|
||||
$absent .= $wpdb->prepare(
|
||||
" AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY %s OR ( %s REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(%s AS DECIMAL(30,6)) ) )",
|
||||
$default,
|
||||
$default,
|
||||
$default
|
||||
);
|
||||
}
|
||||
// Every flat row counts, including rows of other post types or deleted
|
||||
// entities; only sample mode narrows the rows.
|
||||
$extra = array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f WHERE f.`{$col}` IS NOT NULL AND {$absent}" . ( $sample > 0 ? str_replace( '{id}', "f.`{$id}`", $scope ) : '' ) ) );
|
||||
|
||||
$counts = array(
|
||||
'miss' => count( $miss ),
|
||||
'diff' => count( $diff ),
|
||||
'extra' => count( $extra ),
|
||||
);
|
||||
$result['keys'][ $key ] = $counts;
|
||||
$result['miss'] += $counts['miss'];
|
||||
$result['diff'] += $counts['diff'];
|
||||
$result['extra'] += $counts['extra'];
|
||||
$result['ids'] = array_merge( $result['ids'], $miss, $diff, $extra );
|
||||
}
|
||||
|
||||
$result['ids'] = array_values( array_unique( $result['ids'] ) );
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat rows whose entity no longer exists or, for post groups, whose post
|
||||
* is outside the group's post types.
|
||||
*
|
||||
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group Group name.
|
||||
* @param string $table Group flat table.
|
||||
* @return int[]
|
||||
*/
|
||||
private static function stray_ids( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table ): array {
|
||||
global $wpdb;
|
||||
|
||||
$id = $adapter->get_entity_id_column();
|
||||
$primary = $adapter->get_primary_table();
|
||||
$pk = $adapter->get_primary_id_column();
|
||||
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
|
||||
$type_sql = $post_types ? " AND p.post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')" : '';
|
||||
|
||||
return array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f LEFT JOIN `{$primary}` p ON p.`{$pk}` = f.`{$id}`{$type_sql} WHERE p.`{$pk}` IS NULL" ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a row's columns: native holds none of the group's keys. The row is
|
||||
* kept (NULL-only rows are not deleted, as the router may join on them);
|
||||
* required columns go back to their default.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group Group name.
|
||||
* @param string $table Group flat table.
|
||||
* @param string $id Entity id column.
|
||||
* @param int $eid Entity id.
|
||||
*/
|
||||
private static function clear_row( string $entity_type, string $group, string $table, string $id, int $eid ): void {
|
||||
global $wpdb;
|
||||
|
||||
$data = array();
|
||||
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
|
||||
$data[ TMDO_Schema_Manager::sanitize_column_name( (string) $field['key'] ) ] = TMDO_Type_Caster::to_db( null, $field );
|
||||
}
|
||||
$wpdb->update( $table, $data, array( $id => $eid ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL restricting the checked entities: the group's post types, and in
|
||||
* sample mode a random set of entity ids. `{id}` stands for the id column.
|
||||
*
|
||||
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group Group name.
|
||||
* @param int $sample 0, or the number of random entities.
|
||||
*/
|
||||
private static function scope_sql( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, int $sample ): string {
|
||||
global $wpdb;
|
||||
|
||||
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
|
||||
$type_sql = $post_types
|
||||
? " post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')"
|
||||
: '';
|
||||
|
||||
if ( $sample <= 0 ) {
|
||||
return $type_sql ? " AND {id} IN ( SELECT ID FROM {$wpdb->posts} WHERE{$type_sql} )" : '';
|
||||
}
|
||||
|
||||
$primary = $adapter->get_primary_table();
|
||||
$pk = $adapter->get_primary_id_column();
|
||||
$ids = $wpdb->get_col( "SELECT `{$pk}` FROM `{$primary}`" . ( $type_sql ? " WHERE{$type_sql}" : '' ) . ' ORDER BY RAND() LIMIT ' . (int) $sample );
|
||||
return ' AND {id} IN (' . ( $ids ? implode( ',', array_map( 'intval', $ids ) ) : '0' ) . ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a flat value is what native meta would store.
|
||||
*
|
||||
* @param string $native Raw native meta_value.
|
||||
* @param string $flat Flat column value.
|
||||
* @param array<string, mixed> $field Registered field definition.
|
||||
*/
|
||||
private static function same( string $native, string $flat, array $field ): bool {
|
||||
$type = $field['type'] ?? 'text';
|
||||
$value = TMDO_Safe_Unserialize::run( $native );
|
||||
if ( 'text' === $type && is_scalar( $value ) && mb_strlen( (string) $value ) > TMDO_Type_Caster::TEXT_MAX_CHARS ) {
|
||||
return false; // Belongs in flat as NULL, yet this row holds a value.
|
||||
}
|
||||
|
||||
$expected = TMDO_Type_Caster::to_db( $value, $field );
|
||||
if ( null === $expected ) {
|
||||
return false;
|
||||
}
|
||||
if ( 'json' === $type ) {
|
||||
// Key order and int-vs-float formatting are not drift.
|
||||
return json_decode( $flat, true ) == json_decode( (string) $expected, true ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
|
||||
}
|
||||
if ( is_numeric( $expected ) && is_numeric( $flat ) ) {
|
||||
return abs( (float) $expected - (float) $flat ) < 0.000001;
|
||||
}
|
||||
return (string) $expected === $flat;
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ class TMDO_Health_Cron {
|
||||
}
|
||||
} catch ( Throwable $e ) {
|
||||
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check.
|
||||
error_log( '[WPDO] Module detector exception in health cron: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
TMDO_Logger::error( 'health_cron', 'module_detector', $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
|
||||
*
|
||||
* Registers 7 tests under Tools → Site Health → Status:
|
||||
* Registers 10 tests under Tools → Site Health → Status:
|
||||
* 1. wpdo_schema_drift (critical)
|
||||
* 2. wpdo_error_budget (recommended)
|
||||
* 3. wpdo_hook_conflicts (recommended)
|
||||
@@ -10,6 +10,9 @@
|
||||
* 5. wpdo_postmeta_explosion (recommended)
|
||||
* 6. wpdo_orphan_zone_rows (recommended)
|
||||
* 7. wpdo_missing_snapshot (critical)
|
||||
* 8. wpdo_entity_group_collisions (recommended)
|
||||
* 9. wpdo_orphaned_entity_groups (recommended)
|
||||
* 10. wpdo_missing_integration_addons (recommended)
|
||||
*
|
||||
* Each test result is cached for 5 minutes to keep Site Health responsive.
|
||||
*
|
||||
@@ -34,13 +37,16 @@ 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',
|
||||
'wpdo_orphaned_entity_groups' => 'check_orphaned_entity_groups',
|
||||
'wpdo_missing_integration_addons' => 'check_missing_integration_addons',
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -53,7 +59,7 @@ class TMDO_Site_Health {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the 7 tests with WP Site Health.
|
||||
* Register the 10 tests with WP Site Health.
|
||||
*
|
||||
* @param array $tests Existing tests.
|
||||
* @return array
|
||||
@@ -76,13 +82,16 @@ 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' ),
|
||||
'wpdo_orphaned_entity_groups' => __( 'WPDO orphaned entity groups', '2meet-data-optimizer' ),
|
||||
'wpdo_missing_integration_addons' => __( 'WPDO integrations without their AddOn', '2meet-data-optimizer' ),
|
||||
);
|
||||
return $map[ $key ] ?? $key;
|
||||
}
|
||||
@@ -425,6 +434,118 @@ 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<string, mixed> Site Health result.
|
||||
*/
|
||||
public static function check_entity_group_collisions(): array {
|
||||
$collisions = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_collisions() : array();
|
||||
if ( empty( $collisions ) ) {
|
||||
return self::wrap( 'wpdo_entity_group_collisions', self::pass( __( 'No entity group name collisions.', '2meet-data-optimizer' ) ) );
|
||||
}
|
||||
$lines = array();
|
||||
foreach ( $collisions as $c ) {
|
||||
$lines[] = sprintf(
|
||||
'%s/%s (+%s −%s ~%s)',
|
||||
$c['entity_type'],
|
||||
$c['group'],
|
||||
implode( ',', $c['added'] ) ?: '-',
|
||||
implode( ',', $c['removed'] ) ?: '-',
|
||||
implode( ',', $c['changed'] ) ?: '-'
|
||||
);
|
||||
}
|
||||
return self::wrap(
|
||||
'wpdo_entity_group_collisions',
|
||||
self::fail(
|
||||
__( 'WPDO entity group name collisions', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: %s: list of entity/group names with field differences */
|
||||
__( 'Another plugin registered these groups with a different field list; its definition was ignored and the first one kept: %s', '2meet-data-optimizer' ),
|
||||
implode( '; ', $lines )
|
||||
),
|
||||
'recommended'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Test 9: Orphaned entity groups ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Report flat-table groups kept alive from wpdo_registry_meta because no
|
||||
* active plugin registers them any more.
|
||||
*
|
||||
* Not cached for the same reason as check_entity_group_collisions().
|
||||
*
|
||||
* @return array<string, mixed> Site Health result.
|
||||
*/
|
||||
public static function check_orphaned_entity_groups(): array {
|
||||
$restored = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_restored_groups() : array();
|
||||
if ( empty( $restored ) ) {
|
||||
return self::wrap( 'wpdo_orphaned_entity_groups', self::pass( __( 'Every flat-table group is registered by an active plugin.', '2meet-data-optimizer' ) ) );
|
||||
}
|
||||
$lines = array();
|
||||
foreach ( $restored as $r ) {
|
||||
$lines[] = sprintf(
|
||||
'%s/%s (%d fields%s%s)',
|
||||
$r['entity_type'],
|
||||
$r['group'],
|
||||
$r['fields'],
|
||||
$r['skipped'] ? ', skipped: ' . implode( ',', $r['skipped'] ) : '',
|
||||
$r['native'] ? ', left on native meta (newer data there): ' . implode( ',', $r['native'] ) : ''
|
||||
);
|
||||
}
|
||||
return self::wrap(
|
||||
'wpdo_orphaned_entity_groups',
|
||||
self::fail(
|
||||
__( 'WPDO entity groups restored from stored definitions', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: %s: list of entity/group names */
|
||||
__( 'No active plugin registers these groups, so their stored definitions were re-applied to keep the flat-table data readable. Reactivate the owning plugin, or write the data back to native meta before removing it: %s', '2meet-data-optimizer' ),
|
||||
implode( '; ', $lines )
|
||||
),
|
||||
'recommended'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Test 10: Integrations without their AddOn ──────────────────────
|
||||
|
||||
/**
|
||||
* Report HivePress / WooCommerce running while their AddOn is inactive.
|
||||
*
|
||||
* Not cached: two class_exists() probes.
|
||||
*
|
||||
* @return array<string, mixed> Site Health result.
|
||||
*/
|
||||
public static function check_missing_integration_addons(): array {
|
||||
$missing = class_exists( 'TMDO_Compatibility' ) ? TMDO_Compatibility::missing_integration_addons() : array();
|
||||
if ( empty( $missing ) ) {
|
||||
return self::wrap( 'wpdo_missing_integration_addons', self::pass( __( 'Every detected integration has its AddOn active.', '2meet-data-optimizer' ) ) );
|
||||
}
|
||||
$lines = array();
|
||||
foreach ( $missing as $plugin => $addon ) {
|
||||
$lines[] = "{$plugin} → {$addon}";
|
||||
}
|
||||
return self::wrap(
|
||||
'wpdo_missing_integration_addons',
|
||||
self::fail(
|
||||
__( 'WPDO integrations running without their AddOn', '2meet-data-optimizer' ),
|
||||
sprintf(
|
||||
/* translators: %s: "Plugin → AddOn" pairs */
|
||||
__( 'These plugins are active but their AddOn is not, so their fields are not optimised: %s. Activate the AddOn.', '2meet-data-optimizer' ),
|
||||
implode( '; ', $lines )
|
||||
),
|
||||
'recommended'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a raw check result with required Site Health envelope fields.
|
||||
*
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* 判斷條件(皆滿足才觸發):
|
||||
* 1. 當前 mode = shadow_read
|
||||
* 2. 進入 shadow_read 距今 ≥ `min_days`
|
||||
* 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0
|
||||
* 3. 該 entity 每個群組最近一次 drift-check(2 天內,抽樣或全量)皆無漂移
|
||||
* (1.2.0 起取代 shadow_diffs 筆數;無紀錄不升級)
|
||||
*
|
||||
* 這符合「穩定期沒問題就進 production」的安全遷移原則。
|
||||
* 若 diff 出現,規則永不觸發;人工介入即可。
|
||||
@@ -35,6 +36,9 @@ final class TMDO_Auto_Promoter {
|
||||
|
||||
public const CRON_HOOK = 'wpdo_auto_promote_check';
|
||||
|
||||
/** A drift check older than this (seconds) does not count for promotion. */
|
||||
public const DRIFT_MAX_AGE = 2 * DAY_IN_SECONDS;
|
||||
|
||||
public static function init(): void {
|
||||
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
|
||||
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
|
||||
@@ -82,7 +86,7 @@ final class TMDO_Auto_Promoter {
|
||||
/**
|
||||
* 評估單一 entity 是否可升級。
|
||||
*
|
||||
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'has_diff' | 'vetoed'
|
||||
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'drift_not_clean' | 'vetoed' | 'error'
|
||||
*/
|
||||
public static function check_entity( string $type, int $min_days, array $entered_all ): string {
|
||||
$current = TMDO_Mode_Manager::get( $type );
|
||||
@@ -95,14 +99,13 @@ final class TMDO_Auto_Promoter {
|
||||
return 'not_ready';
|
||||
}
|
||||
|
||||
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity();
|
||||
$count = (int) ( $diff_counts[ $type ] ?? 0 );
|
||||
if ( $count > 0 ) {
|
||||
return 'has_diff';
|
||||
if ( ! TMDO_Drift_Check::clean_since( $type, self::DRIFT_MAX_AGE ) ) {
|
||||
return 'drift_not_clean';
|
||||
}
|
||||
|
||||
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)
|
||||
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count );
|
||||
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)。第 4 參數原為
|
||||
// shadow diff 筆數;1.2.0 起改看 drift,固定傳 0。
|
||||
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, 0 );
|
||||
if ( ! $should ) {
|
||||
return 'vetoed';
|
||||
}
|
||||
|
||||
@@ -144,13 +144,14 @@ final class TMDO_Entity_Migration_Engine {
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
++$stats['errors'];
|
||||
error_log(
|
||||
sprintf(
|
||||
'[UAE Migration] Error %s/%s ID=%d: %s',
|
||||
$entity_type,
|
||||
$group_name,
|
||||
$entity_id,
|
||||
$e->getMessage()
|
||||
TMDO_Logger::error(
|
||||
'migration_engine',
|
||||
'migrate_group',
|
||||
$e->getMessage(),
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'group' => $group_name,
|
||||
'entity_id' => $entity_id,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -182,6 +183,28 @@ final class TMDO_Entity_Migration_Engine {
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild one entity's row of a group from native meta.
|
||||
*
|
||||
* @return bool False when native holds none of the group's keys (nothing written).
|
||||
*/
|
||||
public static function migrate_entity( string $entity_type, string $group_name, int $entity_id ): bool {
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
|
||||
if ( ! $adapter || empty( $group_fields ) ) {
|
||||
return false;
|
||||
}
|
||||
return self::migrate_single_entity(
|
||||
$entity_type,
|
||||
$group_name,
|
||||
$entity_id,
|
||||
array_column( $group_fields, 'key' ),
|
||||
array_column( $group_fields, null, 'key' ),
|
||||
$adapter,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 遷移單一 entity 的所有 meta
|
||||
*/
|
||||
@@ -243,6 +266,18 @@ final class TMDO_Entity_Migration_Engine {
|
||||
return false;
|
||||
}
|
||||
|
||||
// REPLACE rewrites the whole row: a column left out falls back to its DDL
|
||||
// default (e.g. session_count DEFAULT 0), inventing a value the object never
|
||||
// had. Keys without a native row are written as NULL; required columns are
|
||||
// NOT NULL, so they keep their default (treated as "absent" by the diff logger).
|
||||
foreach ( $field_map as $key => $field_def ) {
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||
if ( ! array_key_exists( $col, $data ) && empty( $field_def['required'] ) ) {
|
||||
$data[ $col ] = null;
|
||||
$formats[] = '%s';
|
||||
}
|
||||
}
|
||||
|
||||
if ( $dry_run ) {
|
||||
return true;
|
||||
}
|
||||
@@ -349,7 +384,16 @@ final class TMDO_Entity_Migration_Engine {
|
||||
++$batch_migrated;
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
error_log( sprintf( '[UAE Migration Batch] Error %s/%s ID=%d: %s', $entity_type, $group_name, $entity_id, $e->getMessage() ) );
|
||||
TMDO_Logger::error(
|
||||
'migration_engine',
|
||||
'migrate_group_batch',
|
||||
$e->getMessage(),
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'group' => $group_name,
|
||||
'entity_id' => $entity_id,
|
||||
)
|
||||
);
|
||||
}
|
||||
$last_id = $entity_id;
|
||||
}
|
||||
@@ -374,6 +418,23 @@ final class TMDO_Entity_Migration_Engine {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered groups of an entity type that can be backfilled from native
|
||||
* meta. Orphan groups restored from wpdo_registry_meta are left out: their
|
||||
* values live only in the flat table, a backfill would wipe them.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function backfillable_groups( string $entity_type ): array {
|
||||
$orphans = array();
|
||||
foreach ( TMDO_Entity_Registry::get_restored_groups() as $restored ) {
|
||||
if ( $entity_type === $restored['entity_type'] ) {
|
||||
$orphans[] = $restored['group'];
|
||||
}
|
||||
}
|
||||
return array_values( array_diff( TMDO_Entity_Registry::get_groups_for_type( $entity_type ), $orphans ) );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 斷點管理
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,6 +31,24 @@ final class TMDO_Entity_Registry {
|
||||
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
|
||||
private static array $pending_schemas = array();
|
||||
|
||||
/** @var array<string, array<string, array<string, mixed>>> 實體類型 → meta_key → 欄位定義(僅未限定 post_type 的群組)*/
|
||||
private static array $unscoped_index = array();
|
||||
|
||||
/** @var array<string, array<string, array<string, array<string, mixed>>>> 實體類型 → post_type → meta_key → 欄位定義 */
|
||||
private static array $scoped_index = array();
|
||||
|
||||
/** @var array<string, array<string, string[]>> 實體類型 → 群組名 → 限定的 post_type */
|
||||
private static array $scopes = array();
|
||||
|
||||
/** @var array<string, array<string, array<string, string[]>>> 實體類型 → 群組名 → 索引名 → 欄位 key */
|
||||
private static array $indexes = array();
|
||||
|
||||
/** @var array<string, array<string, mixed>> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
|
||||
private static array $collisions = array();
|
||||
|
||||
/** @var array<string, array<string, mixed>> "entity/group" → 從 wpdo_registry_meta 還原的孤兒群組 */
|
||||
private static array $restored = array();
|
||||
|
||||
/** 合法的欄位型別 */
|
||||
public const VALID_TYPES = array(
|
||||
'text',
|
||||
@@ -49,11 +67,20 @@ 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::$scopes = array();
|
||||
self::$indexes = array();
|
||||
self::$pending_schemas = array();
|
||||
self::$collisions = array();
|
||||
self::$restored = array();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -92,10 +119,17 @@ final class TMDO_Entity_Registry {
|
||||
* - unique (bool) 唯一索引
|
||||
* - options (array) enum 選項
|
||||
* - label (string) 顯示名稱
|
||||
* @param array<string, mixed> $args 選填:post_types (string[]) — 群組只套用於這些 post_type。
|
||||
* 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬;
|
||||
* 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。
|
||||
* 不列入欄位定義,所以不影響 schema hash 與衝突比對。
|
||||
* indexes (array<string, string[]>) — 複合索引:名稱 → 欄位 key(依序);
|
||||
* 含未知欄位的索引會被略過。同樣不影響 schema hash,
|
||||
* 由 TMDO_Schema_Manager::ensure_group_indexes() 補建到既有表。
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
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;
|
||||
@@ -105,12 +139,54 @@ final class TMDO_Entity_Registry {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 防重複登錄
|
||||
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
|
||||
$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;
|
||||
}
|
||||
|
||||
// 建立索引以供快速查詢
|
||||
$post_types = array_values( array_filter( array_map( 'strval', (array) ( $args['post_types'] ?? array() ) ) ) );
|
||||
if ( $post_types ) {
|
||||
self::$scopes[ $entity_type ][ $group_name ] = $post_types;
|
||||
}
|
||||
$keys = array_column( $normalized, 'key' );
|
||||
foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) {
|
||||
$columns = array_values( array_map( 'strval', (array) $columns ) );
|
||||
if ( $columns && ! array_diff( $columns, $keys ) ) {
|
||||
self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns;
|
||||
}
|
||||
}
|
||||
foreach ( $normalized as $normalized_field ) {
|
||||
self::index_field( $entity_type, $normalized_field, $post_types );
|
||||
}
|
||||
|
||||
self::$groups[ $entity_type ][ $group_name ] = $normalized;
|
||||
|
||||
// 加入待建表佇列
|
||||
self::$pending_schemas[] = array(
|
||||
'type' => $entity_type,
|
||||
'group' => $group_name,
|
||||
'fields' => $normalized,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗證並正規化欄位定義(略過缺 key/type 或型別不合法者)。
|
||||
* @param array<int, array<string, mixed>> $fields
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function normalize_fields( string $entity_type, string $group_name, array $fields ): array {
|
||||
$normalized = array();
|
||||
|
||||
foreach ( $fields as $field ) {
|
||||
@@ -141,27 +217,148 @@ 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 $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 還原孤兒群組:依 wpdo_registry_meta 保存的定義重新登錄,只供讀寫路由用。
|
||||
*
|
||||
* 與 register_group() 的差異:不進 pending_schemas(絕不觸發 DDL),且已被
|
||||
* 其他存活群組管理的 key 一律略過,不覆蓋。$native_keys 列出原生 meta 仍有
|
||||
* 資料的 key:那些值比扁平表新(孤兒期間寫入),必須留在原生 meta。
|
||||
*
|
||||
* @param string[] $native_keys 原生 meta 仍有資料、不可還原的 key。
|
||||
* @return bool 有登錄任何欄位時為 true
|
||||
* @param array<int, array<string, mixed>> $fields
|
||||
*/
|
||||
public static function restore_group( string $entity_type, string $group_name, array $fields, array $native_keys = array() ): bool {
|
||||
if ( ! isset( self::$adapters[ $entity_type ] ) || isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 加入待建表佇列
|
||||
self::$pending_schemas[] = array(
|
||||
'type' => $entity_type,
|
||||
'group' => $group_name,
|
||||
'fields' => $normalized,
|
||||
$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 ( $kept as $field ) {
|
||||
self::index_field( $entity_type, $field, array() );
|
||||
}
|
||||
self::$groups[ $entity_type ][ $group_name ] = $kept;
|
||||
|
||||
self::$restored[ $id ] = array(
|
||||
'entity_type' => $entity_type,
|
||||
'group' => $group_name,
|
||||
'fields' => count( $kept ),
|
||||
'skipped' => $skipped,
|
||||
'native' => $native,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把欄位加入索引。field_index 保留舊語意(整個實體共用、後註冊者覆蓋),
|
||||
* 供不帶 post_type 的查詢使用;另外依是否限定 post_type 分別建索引。
|
||||
*
|
||||
* @param string[] $post_types 群組限定的 post_type;空陣列表示不限定。
|
||||
* @param array<string, mixed> $field
|
||||
*/
|
||||
private static function index_field( string $entity_type, array $field, array $post_types ): void {
|
||||
self::$field_index[ $entity_type ][ $field['key'] ] = $field;
|
||||
|
||||
if ( empty( $post_types ) ) {
|
||||
self::$unscoped_index[ $entity_type ][ $field['key'] ] = $field;
|
||||
return;
|
||||
}
|
||||
foreach ( $post_types as $post_type ) {
|
||||
self::$scoped_index[ $entity_type ][ $post_type ][ $field['key'] ] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本請求從 wpdo_registry_meta 還原的孤兒群組。
|
||||
*
|
||||
* @return array<int, array{entity_type:string, group:string, fields:int, skipped:string[], native:string[]}>
|
||||
*/
|
||||
public static function get_restored_groups(): array {
|
||||
return array_values( self::$restored );
|
||||
}
|
||||
|
||||
/**
|
||||
* 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。
|
||||
*
|
||||
* 註冊每個請求都會跑,warning 以 transient 節流為每天一次,避免灌爆 wpdo_errors。
|
||||
* @param array<int, array<string, mixed>> $existing
|
||||
* @param array<int, array<string, mixed>> $incoming
|
||||
*/
|
||||
private static function record_collision( string $entity_type, string $group_name, array $existing, array $incoming ): void {
|
||||
$id = $entity_type . '/' . $group_name;
|
||||
if ( isset( self::$collisions[ $id ] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$old = array_column( $existing, null, 'key' );
|
||||
$new = array_column( $incoming, null, 'key' );
|
||||
|
||||
$changed = array();
|
||||
foreach ( array_intersect_key( $new, $old ) as $key => $field ) {
|
||||
if ( $field !== $old[ $key ] ) {
|
||||
$changed[] = (string) $key;
|
||||
}
|
||||
}
|
||||
|
||||
self::$collisions[ $id ] = array(
|
||||
'entity_type' => $entity_type,
|
||||
'group' => $group_name,
|
||||
'added' => array_map( 'strval', array_keys( array_diff_key( $new, $old ) ) ),
|
||||
'removed' => array_map( 'strval', array_keys( array_diff_key( $old, $new ) ) ),
|
||||
'changed' => $changed,
|
||||
);
|
||||
|
||||
$throttle = 'tmdo_group_collision_' . md5( (string) wp_json_encode( self::$collisions[ $id ] ) );
|
||||
if ( class_exists( 'TMDO_Logger' ) && false === get_transient( $throttle ) ) {
|
||||
set_transient( $throttle, 1, DAY_IN_SECONDS );
|
||||
TMDO_Logger::warning( 'entity_group_collision', self::$collisions[ $id ] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本請求偵測到的同名群組衝突(第二份定義已被捨棄)。
|
||||
*
|
||||
* @return array<int, array{entity_type:string, group:string, added:string[], removed:string[], changed:string[]}>
|
||||
*/
|
||||
public static function get_collisions(): array {
|
||||
return array_values( self::$collisions );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 查詢 API
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -169,10 +366,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,6 +392,24 @@ final class TMDO_Entity_Registry {
|
||||
return array_keys( self::$groups[ $entity_type ] ?? array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* 群組宣告的複合索引。
|
||||
*
|
||||
* @return array<string, string[]> 索引名 → 欄位 key
|
||||
*/
|
||||
public static function get_group_indexes( string $entity_type, string $group_name ): array {
|
||||
return self::$indexes[ $entity_type ][ $group_name ] ?? array();
|
||||
}
|
||||
|
||||
/**
|
||||
* 群組限定的 post_type(未限定時為空陣列)。
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function get_group_post_types( string $entity_type, string $group_name ): array {
|
||||
return self::$scopes[ $entity_type ][ $group_name ] ?? array();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得特定群組的完整欄位定義
|
||||
*/
|
||||
|
||||
@@ -44,6 +44,15 @@ final class TMDO_Hook_Bus {
|
||||
// ── 刪除攔截 ──────────────────────────────────────────
|
||||
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
|
||||
|
||||
// ── dual_write / shadow_read:原生寫入完成後,flat 從原生同步 ──
|
||||
// Native is the source of truth in these modes; flat follows what
|
||||
// native actually became (WP may skip or refuse a write).
|
||||
add_action( "added_{$type}_meta", array( self::class, 'on_meta_added' ), 1, 4 );
|
||||
add_action( "updated_{$type}_meta", array( self::class, 'on_meta_updated' ), 1, 4 );
|
||||
add_action( "delete_{$type}_meta", array( self::class, 'before_meta_deleted' ), 1, 4 );
|
||||
add_action( "deleted_{$type}_meta", array( self::class, 'on_meta_deleted' ), 1, 4 );
|
||||
add_filter( "update_{$type}_metadata_by_mid", array( self::class, 'before_update_by_mid' ), 1, 4 );
|
||||
|
||||
// ── 實體刪除時自動清理 ───────────────────────────────
|
||||
add_action(
|
||||
$adapter->get_delete_hook(),
|
||||
@@ -70,26 +79,26 @@ final class TMDO_Hook_Bus {
|
||||
*/
|
||||
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
|
||||
|
||||
// 已在短路中 → 避免遞迴
|
||||
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$type = self::resolve_type_from_current_filter();
|
||||
if ( ! $type ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||
// 已在短路中 → 避免遞迴
|
||||
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
|
||||
if ( ! $field_def ) {
|
||||
return $check; // 非管理欄位,放行
|
||||
}
|
||||
|
||||
// ── Mode-aware dispatch ──────────────────────────────
|
||||
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
|
||||
// dual_write: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
|
||||
// shadow_read: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
|
||||
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
|
||||
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
|
||||
// dual_write : 放行 WP 寫 EAV;updated_/added_ action 再從原生同步 flat
|
||||
// shadow_read: 同 dual_write
|
||||
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
|
||||
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
|
||||
return $check; // disabled
|
||||
}
|
||||
@@ -105,6 +114,13 @@ final class TMDO_Hook_Bus {
|
||||
return true;
|
||||
}
|
||||
|
||||
// dual_write / shadow_read: native first. Writing flat here, before WP
|
||||
// decides whether native changes at all (same value, non-matching
|
||||
// prev_value), let flat drift; sync_from_native() runs after the write.
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
|
||||
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。
|
||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' );
|
||||
@@ -113,27 +129,22 @@ final class TMDO_Hook_Bus {
|
||||
|
||||
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
|
||||
|
||||
// 若 mode 也要寫 EAV → return null 讓 WP 繼續
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null; // dual_write / shadow_read
|
||||
}
|
||||
|
||||
return $flat_result; // aeav_only
|
||||
}
|
||||
|
||||
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
|
||||
|
||||
// 已在短路中 → 避免遞迴
|
||||
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$type = self::resolve_type_from_current_filter();
|
||||
if ( ! $type ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||
// 已在短路中 → 避免遞迴
|
||||
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
|
||||
if ( ! $field_def ) {
|
||||
return $check;
|
||||
}
|
||||
@@ -150,6 +161,12 @@ final class TMDO_Hook_Bus {
|
||||
return true;
|
||||
}
|
||||
|
||||
// dual_write / shadow_read: native first (add($unique) may be refused);
|
||||
// sync_from_native() runs on added_{type}_meta.
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
|
||||
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。
|
||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' );
|
||||
@@ -158,11 +175,228 @@ final class TMDO_Hook_Bus {
|
||||
|
||||
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
|
||||
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
return $flat_result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// dual_write / shadow_read:原生寫入後同步 flat
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Entity IDs whose native rows a pending delete removes, keyed "type:key".
|
||||
*
|
||||
* @var array<string, int[]>
|
||||
*/
|
||||
private static array $pending_deletes = array();
|
||||
|
||||
/**
|
||||
* Old keys of rows renamed through update_metadata_by_mid, keyed by meta ID.
|
||||
*
|
||||
* @var array<string, array{type:string, id:int, key:string}>
|
||||
*/
|
||||
private static array $pending_renames = array();
|
||||
|
||||
/**
|
||||
* Copy the entity's current native value for a key into its flat column
|
||||
* (NULL when native has no row). Runs in dual_write / shadow_read only,
|
||||
* after WP wrote native, so flat always ends up equal to native — also
|
||||
* when WP skipped or refused the write, and when an earlier direct SQL
|
||||
* write had left flat stale. A NULL never creates a row: deleting an
|
||||
* entity drops its flat row before WP deletes the meta rows one by one.
|
||||
*
|
||||
* @param string $type Entity type.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Value the caller wrote (for listeners).
|
||||
* @param string $op 'add' | 'update' | 'delete'.
|
||||
* @param bool $delete_all Whether the delete was a delete_all.
|
||||
* @return void
|
||||
*/
|
||||
public static function sync_from_native( string $type, int $object_id, string $meta_key, $meta_value, string $op, bool $delete_all = false ): void {
|
||||
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return;
|
||||
}
|
||||
$field_def = self::field_for( $type, $object_id, $meta_key );
|
||||
if ( ! $field_def || 'flat' !== self::decide_route( $op, $type, $object_id, $meta_key, $meta_value ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
return $flat_result;
|
||||
$before = self::maybe_read_before_value( $type, $object_id, $meta_key, $field_def, $op );
|
||||
$native = self::native_first_value( $type, $object_id, $meta_key );
|
||||
$result = null === $native
|
||||
? self::clear_column( $type, $object_id, $meta_key, $field_def )
|
||||
: self::perform_upsert( $type, $object_id, $meta_key, maybe_unserialize( $native ), $field_def );
|
||||
|
||||
if ( 'delete' === $op ) {
|
||||
do_action( 'wpdo_after_delete', $type, $object_id, $meta_key, $meta_value, $result, $delete_all, $before );
|
||||
} else {
|
||||
do_action( 'wpdo_after_write', $type, $object_id, $meta_key, $meta_value, $result, $op, $before );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `added_{type}_meta` action.
|
||||
*
|
||||
* @param int $meta_id Meta ID.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
*/
|
||||
public static function on_meta_added( $meta_id, $object_id, $meta_key, $meta_value ): void {
|
||||
$type = self::resolve_type_from_meta_hook();
|
||||
if ( $type ) {
|
||||
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'add' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `updated_{type}_meta` action; also re-syncs the old key of a row renamed
|
||||
* through update_metadata_by_mid.
|
||||
*
|
||||
* @param int $meta_id Meta ID.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
*/
|
||||
public static function on_meta_updated( $meta_id, $object_id, $meta_key, $meta_value ): void {
|
||||
$type = self::resolve_type_from_meta_hook();
|
||||
if ( ! $type ) {
|
||||
return;
|
||||
}
|
||||
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'update' );
|
||||
|
||||
$rename_key = $type . ':' . (int) $meta_id;
|
||||
if ( isset( self::$pending_renames[ $rename_key ] ) ) {
|
||||
$old = self::$pending_renames[ $rename_key ];
|
||||
unset( self::$pending_renames[ $rename_key ] );
|
||||
self::sync_from_native( $type, $old['id'], $old['key'], null, 'delete' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_{type}_metadata_by_mid` filter: remember the old key when a row
|
||||
* is renamed, so its flat column can follow. Never changes $check.
|
||||
*
|
||||
* @param mixed $check Short-circuit value.
|
||||
* @param int $meta_id Meta ID.
|
||||
* @param mixed $meta_value New value.
|
||||
* @param string|false $meta_key New key, or false to keep it.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function before_update_by_mid( $check, $meta_id, $meta_value, $meta_key ) {
|
||||
$type = self::resolve_type_from_meta_hook();
|
||||
if ( ! $type || false === $meta_key ) {
|
||||
return $check;
|
||||
}
|
||||
$row = self::native_row_by_mid( $type, (int) $meta_id );
|
||||
if ( $row && $row['key'] !== $meta_key && TMDO_Entity_Registry::get_field( $type, $row['key'] ) ) {
|
||||
self::$pending_renames[ $type . ':' . (int) $meta_id ] = array(
|
||||
'type' => $type,
|
||||
'id' => $row['id'],
|
||||
'key' => $row['key'],
|
||||
);
|
||||
}
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* `delete_{type}_meta` action (rows still exist): record which entities a
|
||||
* delete touches — a delete_all passes object ID 0.
|
||||
*
|
||||
* @param int[] $meta_ids Meta IDs to delete.
|
||||
* @param int $object_id Entity ID (0 for delete_all).
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
*/
|
||||
public static function before_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
|
||||
$type = self::resolve_type_from_meta_hook();
|
||||
if ( ! $type || ! TMDO_Entity_Registry::get_field( $type, (string) $meta_key ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return;
|
||||
}
|
||||
$ids = array();
|
||||
foreach ( (array) $meta_ids as $mid ) {
|
||||
$row = self::native_row_by_mid( $type, (int) $mid );
|
||||
if ( $row ) {
|
||||
$ids[ $row['id'] ] = $row['id'];
|
||||
}
|
||||
}
|
||||
self::$pending_deletes[ $type . ':' . $meta_key ] = array_values( $ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* `deleted_{type}_meta` action: re-sync every entity the delete touched.
|
||||
*
|
||||
* @param int[] $meta_ids Deleted meta IDs.
|
||||
* @param int $object_id Entity ID (0 for delete_all).
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Meta value.
|
||||
*/
|
||||
public static function on_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
|
||||
$type = self::resolve_type_from_meta_hook();
|
||||
if ( ! $type ) {
|
||||
return;
|
||||
}
|
||||
$pending_key = $type . ':' . $meta_key;
|
||||
$ids = self::$pending_deletes[ $pending_key ] ?? array( (int) $object_id );
|
||||
unset( self::$pending_deletes[ $pending_key ] );
|
||||
|
||||
foreach ( $ids as $id ) {
|
||||
if ( $id > 0 ) {
|
||||
self::sync_from_native( $type, (int) $id, (string) $meta_key, $meta_value, 'delete', 0 === (int) $object_id );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First native value of a key (the one get_*_meta( $id, $key, true ) returns),
|
||||
* read with SQL so no meta filter runs. Null when the entity has no row.
|
||||
*
|
||||
* @param string $type Entity type.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function native_first_value( string $type, int $object_id, string $meta_key ): ?string {
|
||||
global $wpdb;
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||
if ( ! $adapter ) {
|
||||
return null;
|
||||
}
|
||||
$table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||
// get_results(): get_var() turns an empty string into null.
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$pk}` LIMIT 1", $object_id, $meta_key ),
|
||||
ARRAY_A
|
||||
);
|
||||
return $rows ? (string) $rows[0]['meta_value'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity ID and key of a native meta row.
|
||||
*
|
||||
* @param string $type Entity type.
|
||||
* @param int $meta_id Meta ID.
|
||||
* @return array{id:int, key:string}|null
|
||||
*/
|
||||
private static function native_row_by_mid( string $type, int $meta_id ): ?array {
|
||||
global $wpdb;
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||
if ( ! $adapter ) {
|
||||
return null;
|
||||
}
|
||||
$table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||
$row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key AS mkey FROM `{$table}` WHERE `{$pk}` = %d", $meta_id ), ARRAY_A );
|
||||
if ( ! $row ) {
|
||||
return null;
|
||||
}
|
||||
return array(
|
||||
'id' => (int) $row['id'],
|
||||
'key' => (string) $row['mkey'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,6 +525,34 @@ final class TMDO_Hook_Bus {
|
||||
return 'flat';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a flat column to NULL on the entity's existing row; never insert one.
|
||||
*
|
||||
* @param string $type Entity type.
|
||||
* @param int $entity_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param array<string, mixed> $field_def Registered field.
|
||||
* @return bool|null Null when there is no adapter or table.
|
||||
*/
|
||||
private static function clear_column( string $type, int $entity_id, string $meta_key, array $field_def ): ?bool {
|
||||
global $wpdb;
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||
$table = TMDO_Schema_Manager::get_table_name( $type, $field_def['group'] );
|
||||
if ( ! $adapter || ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$result = $wpdb->update( $table, array( $col => null ), array( $adapter->get_entity_id_column() => $entity_id ) );
|
||||
|
||||
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $field_def['group'] );
|
||||
self::touch_posts_last_changed( $type );
|
||||
|
||||
return false !== $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行 Upsert 操作
|
||||
*/
|
||||
@@ -322,25 +584,31 @@ final class TMDO_Hook_Bus {
|
||||
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
|
||||
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
|
||||
|
||||
// 鎖防遞迴
|
||||
$lock_key = $entity_id . ':' . $meta_key;
|
||||
// 鎖防遞迴(含 entity type:user 5 與 post 5 不可互鎖)
|
||||
$lock_key = $type . ':' . $entity_id . ':' . $meta_key;
|
||||
self::$internal_ops[ $lock_key ] = true;
|
||||
|
||||
try {
|
||||
// 單一 upsert,避免 SELECT→UPDATE/INSERT 的 read-modify-write race。
|
||||
// post:新列一併帶入 post_status / post_date 副本(只在 insert 時寫入)。
|
||||
$extra = 'post' === $type ? TMDO_Post_Status_Sync::insert_columns( $entity_id, $group ) : array();
|
||||
$result = TMDO_DB::upsert(
|
||||
$table,
|
||||
array(
|
||||
$id_col => $entity_id,
|
||||
$col => $db_value,
|
||||
array_merge(
|
||||
array(
|
||||
$id_col => $entity_id,
|
||||
$col => $db_value,
|
||||
),
|
||||
$extra
|
||||
),
|
||||
array( $col ),
|
||||
$id_col,
|
||||
array( '%d', $format )
|
||||
array_merge( array( '%d', $format ), array_fill( 0, count( $extra ), '%s' ) )
|
||||
);
|
||||
|
||||
// 清除快取
|
||||
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
|
||||
self::touch_posts_last_changed( $type );
|
||||
|
||||
// 短路回傳 true(WP 認為寫入成功)
|
||||
return $result !== false;
|
||||
@@ -374,7 +642,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;
|
||||
}
|
||||
@@ -382,12 +650,20 @@ final class TMDO_Hook_Bus {
|
||||
// ── Mode-aware dispatch ──────────────────────────────
|
||||
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
|
||||
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
|
||||
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff
|
||||
// shadow_read : 讀取仍走 EAV,抽樣與 flat 比對記錄 diff
|
||||
// aeav_only : 讀取走 UAE flat,不讀 EAV
|
||||
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
|
||||
return $check; // disabled / dual_write
|
||||
}
|
||||
|
||||
// shadow_read serves native: update_metadata() reads the old value
|
||||
// through this filter, and serving the just-upserted flat value made
|
||||
// WP see old === new and skip the native write (dev36, 2026-09-26).
|
||||
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
|
||||
self::maybe_shadow_compare( $type, (int) $object_id, $meta_key, $field_def );
|
||||
return $check;
|
||||
}
|
||||
|
||||
$group = $field_def['group'];
|
||||
$row = self::get_or_load_row( $type, (int) $object_id, $group );
|
||||
|
||||
@@ -395,22 +671,6 @@ final class TMDO_Hook_Bus {
|
||||
$has_value = is_array( $row ) && array_key_exists( $col, $row );
|
||||
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
|
||||
|
||||
// Shadow-read:與 EAV 比對,記錄差異
|
||||
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
|
||||
try {
|
||||
TMDO_Shadow_Diff_Logger::compare_and_log(
|
||||
$type,
|
||||
(int) $object_id,
|
||||
$meta_key,
|
||||
$value,
|
||||
$field_def
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
// 比對失敗不該影響讀取
|
||||
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
// 值為空 → 回 WP 原生慣例
|
||||
if ( empty( $row ) || $value === null || $value === '' ) {
|
||||
return $single ? '' : array();
|
||||
@@ -420,6 +680,36 @@ final class TMDO_Hook_Bus {
|
||||
return $single ? array( $value ) : array( $value );
|
||||
}
|
||||
|
||||
/**
|
||||
* shadow_read: compare the flat value with native meta for a sample of reads.
|
||||
*
|
||||
* Each compare costs a flat row read and a native SQL read, so only a share
|
||||
* of reads is checked (filter `tmdo_shadow_compare_sample_rate`, 0..1,
|
||||
* default 0.1). The logger rate-limits repeated diffs itself.
|
||||
*
|
||||
* @param string $type Entity type.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param array<string, mixed> $field_def Field definition.
|
||||
*/
|
||||
private static function maybe_shadow_compare( string $type, int $object_id, string $meta_key, array $field_def ): void {
|
||||
$rate = (float) apply_filters( 'tmdo_shadow_compare_sample_rate', 0.1, $type, $meta_key );
|
||||
if ( $rate <= 0 || ( $rate < 1 && wp_rand( 1, 1000 ) > $rate * 1000 ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = self::get_or_load_row( $type, $object_id, $field_def['group'] );
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||
$value = array_key_exists( $col, $row ) ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
|
||||
|
||||
try {
|
||||
TMDO_Shadow_Diff_Logger::compare_and_log( $type, $object_id, $meta_key, $value, $field_def );
|
||||
} catch ( \Throwable $e ) {
|
||||
// 比對失敗不該影響讀取
|
||||
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得(或載入)完整列資料,並快取
|
||||
*/
|
||||
@@ -471,7 +761,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;
|
||||
}
|
||||
@@ -489,6 +779,12 @@ final class TMDO_Hook_Bus {
|
||||
return true;
|
||||
}
|
||||
|
||||
// dual_write / shadow_read: WP deletes native (also delete_all);
|
||||
// deleted_{type}_meta re-syncs every touched entity from native.
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 截取 before value(v1.3.1)
|
||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' );
|
||||
|
||||
@@ -569,14 +865,11 @@ final class TMDO_Hook_Bus {
|
||||
TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group );
|
||||
}
|
||||
|
||||
self::touch_posts_last_changed( $type );
|
||||
|
||||
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
|
||||
|
||||
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
|
||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result !== false;
|
||||
return $result !== false; // aeav_only
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -608,6 +901,21 @@ final class TMDO_Hook_Bus {
|
||||
// 工具方法
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flat post writes skip update_metadata()'s own hooks in aeav_only, so
|
||||
* move posts' last_changed here: caches keyed on it (WP query caches,
|
||||
* routed found-rows counts) must not outlive the write.
|
||||
*
|
||||
* @param string $type Entity type.
|
||||
* @return void
|
||||
*/
|
||||
private static function touch_posts_last_changed( string $type ): void {
|
||||
if ( 'post' === $type ) {
|
||||
wp_cache_set_posts_last_changed();
|
||||
TMDO_Post_Query_Router::bump_count_version();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 從當前 filter 名稱推斷實體類型
|
||||
* 例:update_user_metadata → user
|
||||
@@ -622,6 +930,44 @@ final class TMDO_Hook_Bus {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity type from the meta action / by-mid filter being run
|
||||
* (added_user_meta, deleted_post_meta, update_term_metadata_by_mid, …).
|
||||
*/
|
||||
private static function resolve_type_from_meta_hook(): ?string {
|
||||
if ( preg_match( '/^(?:(?:added|updated|delete|deleted)_(post|user|term|comment)_meta|update_(post|user|term|comment)_metadata_by_mid)$/', current_filter(), $matches ) ) {
|
||||
return '' !== $matches[1] ? $matches[1] : $matches[2];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。
|
||||
*
|
||||
* 先做不帶 post_type 的查詢:大多數 key 不受管理,可省下 get_post_type()。
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||
if ( null !== $field_def && 'post' === $type ) {
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
|
||||
}
|
||||
// A group whose table upgrade waits for WP-CLI / WP-Cron stays native
|
||||
// (not in aeav_only, where native is stale; the router agrees).
|
||||
return null !== $field_def && self::deferred( $type, (string) $field_def['group'] ) ? null : $field_def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a group is deferred and so read and written natively.
|
||||
*
|
||||
* @param string $type Entity type.
|
||||
* @param string $group Group name.
|
||||
* @return bool
|
||||
*/
|
||||
private static function deferred( string $type, string $group ): bool {
|
||||
return TMDO_Schema_Manager::is_deferred( $type, $group ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $type );
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接讀取(繞過 WP filter 系統)
|
||||
* 供 wpdo_get_meta() 便利函式使用,性能更好
|
||||
@@ -637,18 +983,22 @@ final class TMDO_Hook_Bus {
|
||||
// 回傳所有群組的所有欄位
|
||||
$result = array();
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
|
||||
$row = self::get_or_load_row( $type, $entity_id, $group );
|
||||
$deferred = self::deferred( $type, $group );
|
||||
$row = $deferred ? array() : self::get_or_load_row( $type, $entity_id, $group );
|
||||
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
||||
$result[ $field['key'] ] = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
|
||||
$result[ $field['key'] ] = $deferred
|
||||
? get_metadata( $type, $entity_id, $field['key'], true )
|
||||
: TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
|
||||
}
|
||||
}
|
||||
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;
|
||||
// A deferred group's value is the native one.
|
||||
return null !== TMDO_Entity_Registry::get_field( $type, $key ) ? get_metadata( $type, $entity_id, $key, true ) : null;
|
||||
}
|
||||
|
||||
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,6 +140,11 @@ final class TMDO_Mode_Manager {
|
||||
);
|
||||
}
|
||||
|
||||
$guarded = TMDO_Promotion_Guard::check_promotion( $entity_type, $new_mode );
|
||||
if ( $guarded ) {
|
||||
return $guarded;
|
||||
}
|
||||
|
||||
$all = self::all();
|
||||
$all[ $entity_type ] = $new_mode;
|
||||
|
||||
@@ -155,7 +159,7 @@ final class TMDO_Mode_Manager {
|
||||
$entered[ $entity_type ] = time();
|
||||
update_option( self::OPT_ENTERED_AT, $entered, false );
|
||||
|
||||
TMDO_Logger::info(
|
||||
TMDO_Logger::notice(
|
||||
'bridge_mode_changed',
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
/**
|
||||
* Copies of posts.post_status / post_date on post entity tables (O-1).
|
||||
*
|
||||
* With the copies (and their `(post_status, post_date)` index) a routed search
|
||||
* can filter and sort inside the flat table instead of joining back to posts.
|
||||
*
|
||||
* Columns only reach a table two ways, never as DDL on a populated table in a
|
||||
* web request:
|
||||
* - a brand-new (empty) group table gets them when it is created;
|
||||
* - `wp tmdo post-status-sync` adds them to existing tables and backfills.
|
||||
*
|
||||
* Per-group state lives in the TMDO_Post_Status_Sync::OPTION option:
|
||||
* 'columns' (added, backfill pending) or 'ready' (every row copied).
|
||||
* Afterwards transition_post_status keeps the copies current, and flat writes
|
||||
* that create a row fill them in.
|
||||
*
|
||||
* @package TMDO
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps post_status / post_date copies on post entity tables.
|
||||
*/
|
||||
final class TMDO_Post_Status_Sync {
|
||||
|
||||
public const OPTION = 'tmdo_post_status_columns';
|
||||
|
||||
private const INDEX = 'idx_post_status_date';
|
||||
|
||||
/**
|
||||
* Hook the sync into WordPress.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'transition_post_status', array( __CLASS__, 'on_transition' ), 10, 3 );
|
||||
add_action( 'wpdo_schema_updated', array( __CLASS__, 'on_schema_updated' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* '' (no columns), 'columns' or 'ready'.
|
||||
*
|
||||
* @param string $group Post group name.
|
||||
* @return string
|
||||
*/
|
||||
public static function state( string $group ): string {
|
||||
$states = get_option( self::OPTION, array() );
|
||||
return is_array( $states ) ? (string) ( $states[ $group ] ?? '' ) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Every post status change or save: refresh the copies in each table that has them.
|
||||
*
|
||||
* @param string $new_status New status.
|
||||
* @param string $old_status Old status.
|
||||
* @param object $post Saved post.
|
||||
* @return void
|
||||
*/
|
||||
public static function on_transition( $new_status, $old_status, $post ): void {
|
||||
global $wpdb;
|
||||
unset( $new_status, $old_status );
|
||||
foreach ( self::groups_with_columns() as $group ) {
|
||||
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
self::forget( $group ); // The table was dropped (retired or uninstalled group).
|
||||
continue;
|
||||
}
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||
$post->post_status,
|
||||
$post->post_date,
|
||||
$post->ID
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A post group table was just created or altered: an empty one gets the
|
||||
* columns now; one holding rows waits for the CLI.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group Group name.
|
||||
* @param string $table Table name.
|
||||
* @return void
|
||||
*/
|
||||
public static function on_schema_updated( $entity_type, $group, $table ): void {
|
||||
global $wpdb;
|
||||
if ( 'post' !== $entity_type || '' !== self::state( $group ) ) {
|
||||
return;
|
||||
}
|
||||
if ( null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||
return;
|
||||
}
|
||||
if ( self::add_columns( $group ) ) {
|
||||
self::set_state( $group, 'ready' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies for a row a flat write is about to create (empty when the
|
||||
* group's table has no such columns).
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $group Group name.
|
||||
* @return array{post_status?:string, post_date?:string}
|
||||
*/
|
||||
public static function insert_columns( int $post_id, string $group ): array {
|
||||
if ( '' === self::state( $group ) ) {
|
||||
return array();
|
||||
}
|
||||
$post = get_post( $post_id );
|
||||
if ( ! $post ) {
|
||||
return array();
|
||||
}
|
||||
return array(
|
||||
'post_status' => $post->post_status,
|
||||
'post_date' => $post->post_date,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the columns to every post group table and copy posts' values in.
|
||||
*
|
||||
* @param bool $dry_run Count mismatching rows only.
|
||||
* @param int $batch Rows per UPDATE.
|
||||
* @return array<string, array{rows:int, mismatched:int}>
|
||||
*/
|
||||
public static function run( bool $dry_run = false, int $batch = 1000 ): array {
|
||||
global $wpdb;
|
||||
$batch = max( 1, $batch );
|
||||
$report = array();
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||||
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
continue;
|
||||
}
|
||||
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||
$report[ $group ] = array(
|
||||
'rows' => $rows,
|
||||
'mismatched' => '' === self::state( $group ) ? $rows : self::mismatches( $group ),
|
||||
);
|
||||
if ( $dry_run || ! self::add_columns( $group ) ) {
|
||||
continue;
|
||||
}
|
||||
$max = (int) $wpdb->get_var( "SELECT MAX(id) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||
for ( $from = 0; $from < $max; $from += $batch ) {
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id SET t.post_status = p.post_status, t.post_date = p.post_date WHERE t.id > %d AND t.id <= %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||
$from,
|
||||
$from + $batch
|
||||
)
|
||||
);
|
||||
}
|
||||
self::set_state( $group, 'ready' );
|
||||
}
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows whose copies differ from posts (rows of deleted posts excluded).
|
||||
*
|
||||
* @param string $group Group name.
|
||||
* @return int
|
||||
*/
|
||||
public static function mismatches( string $group ): int {
|
||||
global $wpdb;
|
||||
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||
return (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id WHERE NOT ( t.post_status <=> p.post_status ) OR NOT ( t.post_date <=> p.post_date )" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add whichever of the two columns and the index the table lacks.
|
||||
*
|
||||
* @param string $group Group name.
|
||||
* @return bool False when the table is missing or the ALTER failed.
|
||||
*/
|
||||
private static function add_columns( string $group ): bool {
|
||||
global $wpdb;
|
||||
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
return false;
|
||||
}
|
||||
$columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||
$indexes = array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||
$changes = array();
|
||||
if ( ! in_array( 'post_status', $columns, true ) ) {
|
||||
// Same collation as posts.post_status: the table default may differ,
|
||||
// and comparing the two columns would then fail (illegal mix of collations).
|
||||
$source = $wpdb->get_row( "SHOW FULL COLUMNS FROM `{$wpdb->posts}` LIKE 'post_status'", ARRAY_A );
|
||||
$collation = preg_replace( '/[^A-Za-z0-9_]/', '', (string) ( $source['Collation'] ?? '' ) );
|
||||
$changes[] = 'ADD COLUMN `post_status` VARCHAR(20)' . ( '' !== $collation ? " COLLATE {$collation}" : '' ) . ' DEFAULT NULL';
|
||||
}
|
||||
if ( ! in_array( 'post_date', $columns, true ) ) {
|
||||
$changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL';
|
||||
}
|
||||
if ( ! in_array( self::INDEX, $indexes, true ) ) {
|
||||
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`)';
|
||||
}
|
||||
if ( $changes && false === $wpdb->query( "ALTER TABLE `{$table}` " . implode( ', ', $changes ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fixed DDL on a registry table name.
|
||||
return false;
|
||||
}
|
||||
if ( '' === self::state( $group ) ) {
|
||||
self::set_state( $group, 'columns' );
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post groups whose table has the columns.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private static function groups_with_columns(): array {
|
||||
$states = get_option( self::OPTION, array() );
|
||||
return is_array( $states ) ? array_keys( $states ) : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a group from the recorded states.
|
||||
*
|
||||
* @param string $group Group name.
|
||||
* @return void
|
||||
*/
|
||||
private static function forget( string $group ): void {
|
||||
$states = get_option( self::OPTION, array() );
|
||||
if ( is_array( $states ) && isset( $states[ $group ] ) ) {
|
||||
unset( $states[ $group ] );
|
||||
update_option( self::OPTION, $states, true );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a group's state.
|
||||
*
|
||||
* @param string $group Group name.
|
||||
* @param string $state 'columns' | 'ready'.
|
||||
* @return void
|
||||
*/
|
||||
private static function set_state( string $group, string $state ): void {
|
||||
$states = get_option( self::OPTION, array() );
|
||||
$states = is_array( $states ) ? $states : array();
|
||||
$states[ $group ] = $state;
|
||||
update_option( self::OPTION, $states, true );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Promotion_Guard — keeps native meta authoritative on HivePress /
|
||||
* WooCommerce sites (ADR-004 "search-index mode").
|
||||
*
|
||||
* Both plugins read native meta without going through a keyed
|
||||
* get_*_meta() call: HivePress models load every key with get_*_meta( $id ),
|
||||
* WooCommerce does the same for products and writes stock / sales with direct
|
||||
* SQL. The Hook Bus cannot serve those reads from flat tables, so on these
|
||||
* sites an entity must never reach aeav_only and native rows must never be
|
||||
* cleaned up. Demotion always stays open.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses aeav_only promotion and native-meta cleanup where an integration
|
||||
* reads native meta directly.
|
||||
*/
|
||||
final class TMDO_Promotion_Guard {
|
||||
|
||||
/**
|
||||
* Entity types each integration reads natively.
|
||||
*
|
||||
* HivePress models (Post, User, Term, Comment) all load meta with an empty
|
||||
* key; WooCommerce does so for products and customers.
|
||||
*/
|
||||
private const GUARDED = array(
|
||||
'HivePress' => array( 'post', 'user', 'term', 'comment' ),
|
||||
'WooCommerce' => array( 'post', 'user' ),
|
||||
);
|
||||
|
||||
/**
|
||||
* Check a mode change. Only promotion to aeav_only is guarded.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $new_mode Target mode.
|
||||
* @return WP_Error|null Error when refused.
|
||||
*/
|
||||
public static function check_promotion( string $entity_type, string $new_mode ): ?WP_Error {
|
||||
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $new_mode ) {
|
||||
return null;
|
||||
}
|
||||
return self::refuse( $entity_type, TMDO_Mode_Manager::MODE_AEAV_ONLY );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a cleanup of the entity's native managed meta rows.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @return WP_Error|null Error when refused.
|
||||
*/
|
||||
public static function check_cleanup( string $entity_type ): ?WP_Error {
|
||||
return self::refuse( $entity_type, 'cleanup' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarded entity types that are already in aeav_only (promoted before the
|
||||
* guard existed). Native meta may be stale there.
|
||||
*
|
||||
* @return array<string, string[]> Entity type → integration names.
|
||||
*/
|
||||
public static function violations(): array {
|
||||
$out = array();
|
||||
foreach ( TMDO_Mode_Manager::all() as $type => $mode ) {
|
||||
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
|
||||
continue;
|
||||
}
|
||||
$integrations = self::integrations_for( $type );
|
||||
if ( ! empty( $integrations ) ) {
|
||||
$out[ $type ] = $integrations;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active integrations that read this entity type natively.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @return string[]
|
||||
*/
|
||||
private static function integrations_for( string $entity_type ): array {
|
||||
$active = array(
|
||||
'HivePress' => TMDO_Compatibility::is_hivepress_active(),
|
||||
'WooCommerce' => TMDO_Compatibility::is_woocommerce_active(),
|
||||
);
|
||||
|
||||
$names = array();
|
||||
foreach ( self::GUARDED as $name => $types ) {
|
||||
if ( $active[ $name ] && in_array( $entity_type, $types, true ) ) {
|
||||
$names[] = $name;
|
||||
}
|
||||
}
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the refusal, unless no integration applies or a filter allows it.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $action 'aeav_only' or 'cleanup'.
|
||||
* @return WP_Error|null
|
||||
*/
|
||||
private static function refuse( string $entity_type, string $action ): ?WP_Error {
|
||||
$integrations = self::integrations_for( $entity_type );
|
||||
if ( empty( $integrations ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow a guarded aeav_only promotion or cleanup anyway.
|
||||
*
|
||||
* @param bool $allow Default false.
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $action 'aeav_only' or 'cleanup'.
|
||||
* @param string[] $integrations Integrations that read this entity natively.
|
||||
*/
|
||||
if ( apply_filters( 'tmdo_promotion_guard_allow', false, $entity_type, $action, $integrations ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WP_Error(
|
||||
'promotion_guarded',
|
||||
sprintf(
|
||||
/* translators: 1: integration names, 2: entity type, 3: aeav_only or cleanup */
|
||||
__( '%1$s 直接讀取原生 meta,%2$s 不能執行 %3$s:這類站台以原生 meta 為唯一資料來源(搜尋索引模式)。', '2meet-data-optimizer' ),
|
||||
implode( ' / ', $integrations ),
|
||||
$entity_type,
|
||||
$action
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,15 @@ defined( 'ABSPATH' ) || exit;
|
||||
|
||||
final class TMDO_Schema_Manager {
|
||||
|
||||
/** Option: "entity/group" → md5 of the composite index definition already applied. */
|
||||
public const INDEXES_OPTION = 'tmdo_entity_indexes';
|
||||
|
||||
/** Single cron event that adds declared indexes a visitor request had to leave out. */
|
||||
public const INDEXES_HOOK = 'tmdo_ensure_group_indexes';
|
||||
|
||||
/** Option: "entity/group" → time an existing table's upgrade was left to WP-CLI / WP-Cron. */
|
||||
public const DEFERRED_OPTION = 'tmdo_schema_deferred';
|
||||
|
||||
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
|
||||
private static array $table_exists_cache = array();
|
||||
|
||||
@@ -60,6 +69,7 @@ final class TMDO_Schema_Manager {
|
||||
$schema['group'],
|
||||
$schema['fields']
|
||||
);
|
||||
self::ensure_group_indexes( $schema['type'], $schema['group'] );
|
||||
}
|
||||
|
||||
TMDO_Entity_Registry::clear_pending_schemas();
|
||||
@@ -73,13 +83,13 @@ final class TMDO_Schema_Manager {
|
||||
string $group_name,
|
||||
array $field_definitions
|
||||
): bool {
|
||||
global $wpdb;
|
||||
|
||||
// Schema 版本比對:若未變動則跳過
|
||||
$schema_hash = self::calculate_schema_hash( $field_definitions );
|
||||
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
|
||||
|
||||
if ( $stored_hash === $schema_hash ) {
|
||||
// Upgraded meanwhile, or the definition went back to the stored one.
|
||||
self::end_deferral( $entity_type, $group_name );
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -88,7 +98,55 @@ final class TMDO_Schema_Manager {
|
||||
return false;
|
||||
}
|
||||
|
||||
$table = self::get_table_name( $entity_type, $group_name );
|
||||
$table = self::get_table_name( $entity_type, $group_name );
|
||||
if ( self::upgrade_waits( $entity_type, $table ) ) {
|
||||
self::defer_upgrade( "{$entity_type}/{$group_name}" );
|
||||
return false;
|
||||
}
|
||||
if ( ! self::table_exists( $table ) ) {
|
||||
if ( ! self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ) ) {
|
||||
return false;
|
||||
}
|
||||
// A new table starts empty while native meta may already hold the
|
||||
// group's values: native until the drift fix has filled it in.
|
||||
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type ) ) {
|
||||
self::record_deferred( "{$entity_type}/{$group_name}" );
|
||||
self::end_deferral( $entity_type, $group_name );
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// An existing table is altered by one process at a time (anonymous
|
||||
// /wp-cron.php requests also pass ddl_allowed()); a process that waited
|
||||
// finds the upgrade done.
|
||||
if ( ! self::lock() ) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if ( self::get_stored_schema_hash( $entity_type, $group_name ) === $schema_hash ) {
|
||||
return true;
|
||||
}
|
||||
return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash );
|
||||
} finally {
|
||||
self::unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or alter the table (dbDelta) and record its definition.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group_name Group name.
|
||||
* @param array<int, array<string, mixed>> $field_definitions Normalized fields.
|
||||
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||
* @param string $table Table name.
|
||||
* @param string $schema_hash Definition hash.
|
||||
* @return bool
|
||||
*/
|
||||
private static function write_table( string $entity_type, string $group_name, array $field_definitions, TMDO_Entity_Adapter_Interface $adapter, string $table, string $schema_hash ): bool {
|
||||
global $wpdb;
|
||||
$existed = self::table_exists( $table );
|
||||
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
@@ -140,6 +198,11 @@ final class TMDO_Schema_Manager {
|
||||
}
|
||||
}
|
||||
|
||||
// A new table gets its declared composite indexes while it is empty.
|
||||
foreach ( TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name ) as $name => $keys ) {
|
||||
$sql_indexes[] = 'KEY `cidx_' . self::sanitize_column_name( (string) $name ) . '` (' . implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) ) . ')';
|
||||
}
|
||||
|
||||
$columns_sql = implode( ",\n ", $sql_columns );
|
||||
$indexes_sql = implode( ",\n ", $sql_indexes );
|
||||
|
||||
@@ -153,8 +216,27 @@ final class TMDO_Schema_Manager {
|
||||
// Bust the request-scoped table_exists cache so subsequent calls see the new table.
|
||||
self::$table_exists_cache[ $table ] = true;
|
||||
|
||||
// dbDelta reports no failure: record the definition only when every column is there.
|
||||
$columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" );
|
||||
$missing = array_diff( array_map( static fn( $field ) => self::sanitize_column_name( (string) $field['key'] ), $field_definitions ), $columns );
|
||||
if ( $missing ) {
|
||||
unset( self::$table_exists_cache[ $table ] );
|
||||
TMDO_Logger::warning(
|
||||
'schema.upgrade_failed',
|
||||
array(
|
||||
'table' => $table,
|
||||
'missing' => array_values( $missing ),
|
||||
'error' => $wpdb->last_error,
|
||||
)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 記錄 Schema 版本與定義
|
||||
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
|
||||
if ( $existed ) {
|
||||
self::end_deferral( $entity_type, $group_name );
|
||||
}
|
||||
|
||||
/**
|
||||
* Action: 表建立/升級完成
|
||||
@@ -164,6 +246,282 @@ final class TMDO_Schema_Manager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the group's declared composite indexes that its table lacks.
|
||||
*
|
||||
* Indexes are not part of the schema hash, so an existing table (which
|
||||
* may already hold aeav_only data) gets them in place — no rebuild. The
|
||||
* applied definition is remembered per group (one autoloaded option), so
|
||||
* requests only run SHOW INDEX / ALTER when the declaration changes.
|
||||
*
|
||||
* On a large table the ALTER takes seconds, so it runs only from WP-CLI or
|
||||
* WP-Cron (ddl_allowed(); other requests schedule INDEXES_HOOK) and in one
|
||||
* process at a time (MySQL GET_LOCK on index_lock_name()). Until every
|
||||
* ALTER succeeds the group stays in pending_indexes() and the table works
|
||||
* without the index.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group_name Group name.
|
||||
* @return void
|
||||
*/
|
||||
public static function ensure_group_indexes( string $entity_type, string $group_name ): void {
|
||||
global $wpdb;
|
||||
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
|
||||
if ( empty( $declared ) ) {
|
||||
return;
|
||||
}
|
||||
$id = "{$entity_type}/{$group_name}";
|
||||
$signature = md5( (string) wp_json_encode( $declared ) );
|
||||
$applied = get_option( self::INDEXES_OPTION, array() );
|
||||
$applied = is_array( $applied ) ? $applied : array();
|
||||
if ( ( $applied[ $id ] ?? '' ) === $signature ) {
|
||||
return;
|
||||
}
|
||||
if ( ! self::ddl_allowed() ) {
|
||||
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
|
||||
wp_schedule_single_event( time(), self::INDEXES_HOOK );
|
||||
}
|
||||
return;
|
||||
}
|
||||
$table = self::get_table_name( $entity_type, $group_name );
|
||||
if ( ! self::table_exists( $table ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! self::lock() ) {
|
||||
return; // Another process is adding indexes.
|
||||
}
|
||||
try {
|
||||
// SHOW INDEX (not the option, cached for this request) tells what another process already added.
|
||||
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
|
||||
$failed = false;
|
||||
foreach ( $declared as $name => $keys ) {
|
||||
$index = 'cidx_' . self::sanitize_column_name( $name );
|
||||
if ( in_array( $index, $existing, true ) ) {
|
||||
continue;
|
||||
}
|
||||
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
|
||||
if ( false === $wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" ) ) {
|
||||
$failed = true;
|
||||
TMDO_Logger::warning(
|
||||
'schema.index_failed',
|
||||
array(
|
||||
'table' => $table,
|
||||
'index' => $index,
|
||||
'error' => $wpdb->last_error,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $failed ) {
|
||||
$applied[ $id ] = $signature;
|
||||
update_option( self::INDEXES_OPTION, $applied, true );
|
||||
}
|
||||
} finally {
|
||||
self::unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INDEXES_HOOK handler: the table upgrades and indexes a web request left.
|
||||
* The cron request's init usually did them already; not with
|
||||
* ALTERNATE_WP_CRON, where the event runs inside a visitor's request.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function ensure_pending_indexes(): void {
|
||||
foreach ( array_keys( (array) get_option( self::DEFERRED_OPTION, array() ) ) as $id ) {
|
||||
list( $entity_type, $group_name ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' );
|
||||
$fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
|
||||
if ( $fields ) {
|
||||
self::create_or_upgrade_table( $entity_type, $group_name, $fields );
|
||||
}
|
||||
}
|
||||
foreach ( self::pending_indexes() as $id ) {
|
||||
list( $entity_type, $group_name ) = explode( '/', $id, 2 );
|
||||
self::ensure_group_indexes( $entity_type, $group_name );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the group's table upgrade is waiting for WP-CLI / WP-Cron; its
|
||||
* keys then stay native (Hook Bus, query router).
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group_name Group name.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_deferred( string $entity_type, string $group_name ): bool {
|
||||
$deferred = get_option( self::DEFERRED_OPTION, array() );
|
||||
return is_array( $deferred ) && isset( $deferred[ "{$entity_type}/{$group_name}" ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an existing table's upgrade waits for WP-CLI / WP-Cron: altering
|
||||
* a large table takes seconds to minutes, as for indexes. Until then the
|
||||
* group reads and writes native meta (is_deferred()). Not in aeav_only,
|
||||
* where the flat table is the only copy: that upgrade runs at once.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $table Flat table name.
|
||||
* @return bool
|
||||
*/
|
||||
public static function upgrade_waits( string $entity_type, string $table ): bool {
|
||||
return self::table_exists( $table )
|
||||
&& ! self::ddl_allowed()
|
||||
&& TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type );
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a deferred table upgrade and have WP-Cron run it.
|
||||
*
|
||||
* @param string $id "entity/group".
|
||||
* @return void
|
||||
*/
|
||||
private static function defer_upgrade( string $id ): void {
|
||||
self::record_deferred( $id );
|
||||
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
|
||||
wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a group as deferred (native until resynced()).
|
||||
*
|
||||
* @param string $id "entity/group".
|
||||
* @return void
|
||||
*/
|
||||
private static function record_deferred( string $id ): void {
|
||||
$deferred = get_option( self::DEFERRED_OPTION, array() );
|
||||
$deferred = is_array( $deferred ) ? $deferred : array();
|
||||
if ( ! isset( $deferred[ $id ] ) ) {
|
||||
$deferred[ $id ] = time();
|
||||
update_option( self::DEFERRED_OPTION, $deferred, true );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A deferred upgrade is done. Writes made meanwhile skipped the flat table,
|
||||
* so the group stays native until a drift fix (tmdo_drift_resync, a minute
|
||||
* later) has rebuilt it from native and calls resynced(). With no flat
|
||||
* writes (disabled), nothing is behind.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group_name Group name.
|
||||
* @return void
|
||||
*/
|
||||
private static function end_deferral( string $entity_type, string $group_name ): void {
|
||||
if ( ! self::is_deferred( $entity_type, $group_name ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) {
|
||||
$deferred = (array) get_option( self::DEFERRED_OPTION, array() );
|
||||
unset( $deferred[ "{$entity_type}/{$group_name}" ] );
|
||||
update_option( self::DEFERRED_OPTION, $deferred, true );
|
||||
return;
|
||||
}
|
||||
if ( ! wp_next_scheduled( TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ) ) {
|
||||
wp_schedule_single_event( time() + MINUTE_IN_SECONDS, TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A drift fix of the entity succeeded: its deferred groups whose table is
|
||||
* upgraded read and write the flat table again.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @return void
|
||||
*/
|
||||
public static function resynced( string $entity_type ): void {
|
||||
$deferred = get_option( self::DEFERRED_OPTION, array() );
|
||||
if ( ! is_array( $deferred ) || ! $deferred ) {
|
||||
return;
|
||||
}
|
||||
foreach ( array_keys( $deferred ) as $id ) {
|
||||
list( $type, $group ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' );
|
||||
if ( $type === $entity_type && self::get_stored_schema_hash( $type, $group ) === self::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) {
|
||||
unset( $deferred[ $id ] );
|
||||
}
|
||||
}
|
||||
update_option( self::DEFERRED_OPTION, $deferred, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the schema lock without waiting.
|
||||
*
|
||||
* add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two
|
||||
* processes both "acquire" an option lock; GET_LOCK is atomic and is
|
||||
* released by MySQL if this process dies. Re-entrant per connection.
|
||||
*
|
||||
* @return bool False when another process holds it.
|
||||
*/
|
||||
private static function lock(): bool {
|
||||
global $wpdb;
|
||||
if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) {
|
||||
// HyperDB / LudicrousDB would send the SELECT GET_LOCK to a replica,
|
||||
// where it locks nothing against the ALTER on the primary.
|
||||
$wpdb->send_reads_to_masters();
|
||||
}
|
||||
return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', self::index_lock_name() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the schema lock.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function unlock(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', self::index_lock_name() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Key of the MySQL lock held while indexes are added. Lock names are
|
||||
* server-wide, so SQL hashes it with DATABASE() to keep sites apart.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function index_lock_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . self::INDEXES_OPTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request may alter flat tables: WP-CLI or WP-Cron only.
|
||||
* Front-end, REST, admin-ajax, admin-post and even /wp-admin/ requests
|
||||
* (init runs before auth_redirect()) can be a visitor's, who would wait
|
||||
* for the ALTER. Filter: `tmdo_schema_ddl_allowed`.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function ddl_allowed(): bool {
|
||||
$allowed = ( defined( 'WP_CLI' ) && WP_CLI )
|
||||
|| ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() );
|
||||
return (bool) apply_filters( 'tmdo_schema_ddl_allowed', $allowed );
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered groups whose declared indexes are not on their table yet.
|
||||
*
|
||||
* @return string[] "entity/group"
|
||||
*/
|
||||
public static function pending_indexes(): array {
|
||||
$applied = get_option( self::INDEXES_OPTION, array() );
|
||||
$applied = is_array( $applied ) ? $applied : array();
|
||||
$pending = array();
|
||||
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group_name ) {
|
||||
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
|
||||
if ( $declared && ( $applied[ "{$entity_type}/{$group_name}" ] ?? '' ) !== md5( (string) wp_json_encode( $declared ) )
|
||||
&& self::table_exists( self::get_table_name( $entity_type, $group_name ) ) ) {
|
||||
$pending[] = "{$entity_type}/{$group_name}";
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* 計算 Schema Hash(用於偵測欄位變動)
|
||||
*/
|
||||
@@ -268,6 +626,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
|
||||
*/
|
||||
|
||||
@@ -31,6 +31,14 @@ final class TMDO_Shadow_Diff_Logger {
|
||||
/** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */
|
||||
public const RATELIMIT_WINDOW = 300;
|
||||
|
||||
/**
|
||||
* Rows per entity type + key per KEY_WINDOW, across all objects: a key
|
||||
* stale on every object (WooCommerce total_sales, written by direct SQL)
|
||||
* must not log a row for each object read.
|
||||
*/
|
||||
public const KEY_LIMIT = 20;
|
||||
public const KEY_WINDOW = 3600;
|
||||
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'wpdo_shadow_diffs';
|
||||
@@ -69,7 +77,7 @@ final class TMDO_Shadow_Diff_Logger {
|
||||
}
|
||||
|
||||
// 不一致 → 檢查 rate limit
|
||||
if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) ) {
|
||||
if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) || self::key_over_limit( $entity_type, $meta_key ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,6 +191,21 @@ final class TMDO_Shadow_Diff_Logger {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count this diff against the key's KEY_LIMIT; true once the limit is
|
||||
* reached within the current KEY_WINDOW (the window starts at its first row).
|
||||
*/
|
||||
private static function key_over_limit( string $entity_type, string $meta_key ): bool {
|
||||
$key = 'wpdo_sd_kl_' . md5( "{$entity_type}:{$meta_key}" );
|
||||
$state = get_transient( $key );
|
||||
$state = is_array( $state ) ? $state : array( 0, time() );
|
||||
if ( $state[0] >= self::KEY_LIMIT ) {
|
||||
return true;
|
||||
}
|
||||
set_transient( $key, array( $state[0] + 1, $state[1] ), max( 1, $state[1] + self::KEY_WINDOW - time() ) );
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入 diff row
|
||||
*
|
||||
|
||||
@@ -70,10 +70,12 @@ final class TMDO_Type_Caster {
|
||||
return $value;
|
||||
|
||||
case 'text':
|
||||
return (string) $value;
|
||||
return self::fit_text( is_scalar( $value ) ? (string) $value : maybe_serialize( $value ), $field_def );
|
||||
|
||||
case 'textarea':
|
||||
return (string) $value;
|
||||
// Arrays / objects are serialized like WP stores them in native meta;
|
||||
// (string) would store the literal "Array".
|
||||
return is_scalar( $value ) ? (string) $value : maybe_serialize( $value );
|
||||
|
||||
default:
|
||||
// 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為)
|
||||
@@ -81,6 +83,33 @@ final class TMDO_Type_Caster {
|
||||
}
|
||||
}
|
||||
|
||||
/** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */
|
||||
public const TEXT_MAX_CHARS = 255;
|
||||
|
||||
/**
|
||||
* Keep a text value only if it fits its VARCHAR(255) column; otherwise
|
||||
* store NULL (native meta keeps the full value) and log the overflow.
|
||||
*
|
||||
* @param string $value Value to store.
|
||||
* @param array<string, mixed> $field_def Field definition.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function fit_text( string $value, array $field_def ): ?string {
|
||||
$length = mb_strlen( $value );
|
||||
if ( $length <= self::TEXT_MAX_CHARS ) {
|
||||
return $value;
|
||||
}
|
||||
TMDO_Logger::warning(
|
||||
'text_overflow',
|
||||
array(
|
||||
'group' => $field_def['group'] ?? '',
|
||||
'key' => $field_def['key'] ?? '',
|
||||
'length' => $length,
|
||||
)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* DB → PHP 轉換(讀取)
|
||||
*/
|
||||
@@ -111,9 +140,12 @@ final class TMDO_Type_Caster {
|
||||
return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value;
|
||||
|
||||
case 'enum':
|
||||
return (string) $value;
|
||||
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
return (string) $value;
|
||||
// Arrays were stored serialized (to_db); plain strings pass through.
|
||||
return TMDO_Safe_Unserialize::run( (string) $value );
|
||||
|
||||
default:
|
||||
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
|
||||
|
||||
@@ -15,13 +15,13 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers four user entity groups for membership, activity, profile, and SSO.
|
||||
* Registers the user entity groups for membership, activity and profile.
|
||||
*
|
||||
* Groups → flat tables:
|
||||
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
|
||||
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
|
||||
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
|
||||
* sso → wp_wpdo_user_sso (Hub token cache, replaces _tmso_* usermeta)
|
||||
* (sso → wp_wpdo_user_sso is owned by 2meet-data-optimizer-sso-addon)
|
||||
*
|
||||
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
|
||||
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
|
||||
@@ -43,6 +43,7 @@ final class TMDO_Member_Fields {
|
||||
* V2.7.0: Adds four legacy-key groups (core_profile, social, commerce, hp_user)
|
||||
* 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,
|
||||
@@ -60,11 +61,9 @@ final class TMDO_Member_Fields {
|
||||
self::register_membership_group();
|
||||
self::register_activity_group();
|
||||
self::register_profile_group();
|
||||
self::register_sso_group();
|
||||
self::register_core_profile_group();
|
||||
self::register_social_group();
|
||||
self::register_commerce_group();
|
||||
self::register_hp_user_group();
|
||||
self::register_admin_prefs_group();
|
||||
}
|
||||
|
||||
@@ -213,70 +212,6 @@ final class TMDO_Member_Fields {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hub/Spoke SSO token cache — replaces _tmso_* usermeta.
|
||||
*
|
||||
* Silent refresh fires every 15 min per user; at 10M users this is a
|
||||
* high-frequency EAV hot-spot. A flat table + object cache hit cuts DB
|
||||
* load 10–50× versus a wp_usermeta EAV scan per refresh.
|
||||
*
|
||||
* last_id_token is NOT stored in plaintext (privacy + volume). Only the
|
||||
* SHA-256 hash is kept for SLO token comparison.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_sso_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'sso',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hub_global_user_id',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'Hub global user UUID (2mso_user_mapping.global_user_id bridge key)',
|
||||
),
|
||||
array(
|
||||
'key' => 'picture_url',
|
||||
'type' => 'text',
|
||||
'label' => 'Social / SSO profile picture URL',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_id_token_hash',
|
||||
'type' => 'text',
|
||||
'label' => 'SHA-256(last_id_token) for SLO comparison — no plaintext stored',
|
||||
),
|
||||
array(
|
||||
'key' => 'refresh_token_enc',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Encrypted refresh token (TMSO_Crypto — key-versioned enc_vN:ciphertext)',
|
||||
),
|
||||
array(
|
||||
'key' => 'token_expires_at',
|
||||
'type' => 'datetime',
|
||||
'searchable' => true,
|
||||
'label' => 'SSO token expiry (set to past to force re-auth on next request)',
|
||||
),
|
||||
array(
|
||||
'key' => 'sso_last_login_at',
|
||||
'type' => 'datetime',
|
||||
'label' => 'Last SSO-initiated login datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'sso_login_count',
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'label' => 'SSO login count',
|
||||
),
|
||||
array(
|
||||
'key' => 'known_login_ips',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Recent login IPs JSON array (new-device notification) — replaces _tmso_known_ips usermeta',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WP core user fields stored as multi-row EAV in wp_usermeta.
|
||||
*
|
||||
@@ -414,34 +349,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
|
||||
|
||||
@@ -2,28 +2,27 @@
|
||||
/**
|
||||
* Post entity field registration for WP Data Optimizer (v2.9.1).
|
||||
*
|
||||
* Registers seven post entity groups designed to absorb the bulk of
|
||||
* Registers three 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.
|
||||
*
|
||||
* Groups → flat tables (post_type targets):
|
||||
* 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 three 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,10 +38,52 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers seven post entity groups.
|
||||
* Registers three post entity groups. WooCommerce product meta is not one of
|
||||
* them (1.2.0): WooCommerce reads it directly and writes stock by SQL, so
|
||||
* native meta stays its only copy. Existing wp_wpdo_post_wc_product tables
|
||||
* are left alone (see `wp tmdo bridge-restore-native post --group=wc_product --retire`).
|
||||
*/
|
||||
final class TMDO_Post_Fields {
|
||||
|
||||
/**
|
||||
* Post type of each core group that is not scoped to post types in the
|
||||
* registry (wp_core spans every post type and is absent on purpose).
|
||||
*/
|
||||
private const GROUP_POST_TYPES = array(
|
||||
'attachment' => 'attachment',
|
||||
'nav_menu_item' => 'nav_menu_item',
|
||||
);
|
||||
|
||||
/**
|
||||
* Primary post type of a post group: the registry scope for scoped groups
|
||||
* (e.g. the HivePress AddOn's), the core mapping otherwise.
|
||||
*
|
||||
* @param string $group Entity group name.
|
||||
* @return string|null Null for cross-post-type groups such as wp_core.
|
||||
*/
|
||||
public static function post_type_for_group( string $group ): ?string {
|
||||
$scoped = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_group_post_types( 'post', $group ) : array();
|
||||
return $scoped[0] ?? ( self::GROUP_POST_TYPES[ $group ] ?? null );
|
||||
}
|
||||
|
||||
/**
|
||||
* Post groups dedicated to a post type (wp_core excluded).
|
||||
*
|
||||
* @param string $post_type Post type slug.
|
||||
* @return string[]
|
||||
*/
|
||||
public static function groups_for_post_type( string $post_type ): array {
|
||||
$groups = array_keys( self::GROUP_POST_TYPES, $post_type, true );
|
||||
if ( class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||||
if ( in_array( $post_type, TMDO_Entity_Registry::get_group_post_types( 'post', $group ), true ) ) {
|
||||
$groups[] = $group;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_values( array_unique( $groups ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook into WPDO entity field registration.
|
||||
*
|
||||
@@ -64,10 +105,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();
|
||||
}
|
||||
|
||||
@@ -139,298 +176,6 @@ final class TMDO_Post_Fields {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WooCommerce product core meta keys (post_type=product).
|
||||
* 19 keys — covers ~94% of wp_postmeta rows for products on dev10.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_wc_product_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'wc_product',
|
||||
array(
|
||||
array(
|
||||
'key' => '_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Effective price',
|
||||
),
|
||||
array(
|
||||
'key' => '_regular_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Regular price',
|
||||
),
|
||||
array(
|
||||
'key' => '_sale_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Sale price',
|
||||
),
|
||||
array(
|
||||
'key' => '_stock',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Stock quantity',
|
||||
),
|
||||
array(
|
||||
'key' => '_stock_status',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'instock', 'outofstock', 'onbackorder' ),
|
||||
'label' => 'Stock status',
|
||||
),
|
||||
array(
|
||||
'key' => '_sku',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'Product SKU',
|
||||
),
|
||||
array(
|
||||
'key' => '_manage_stock',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Manage stock?',
|
||||
),
|
||||
array(
|
||||
'key' => '_backorders',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no', 'notify' ),
|
||||
'label' => 'Allow backorders?',
|
||||
),
|
||||
array(
|
||||
'key' => '_sold_individually',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Sold individually?',
|
||||
),
|
||||
array(
|
||||
'key' => '_virtual',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Virtual product?',
|
||||
),
|
||||
array(
|
||||
'key' => '_downloadable',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Downloadable?',
|
||||
),
|
||||
array(
|
||||
'key' => '_tax_class',
|
||||
'type' => 'text',
|
||||
'label' => 'Tax class slug',
|
||||
),
|
||||
array(
|
||||
'key' => '_tax_status',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'taxable', 'shipping', 'none' ),
|
||||
'label' => 'Tax status',
|
||||
),
|
||||
array(
|
||||
'key' => '_download_limit',
|
||||
'type' => 'integer',
|
||||
'label' => 'Download limit',
|
||||
),
|
||||
array(
|
||||
'key' => '_download_expiry',
|
||||
'type' => 'integer',
|
||||
'label' => 'Download expiry days',
|
||||
),
|
||||
array(
|
||||
'key' => '_product_version',
|
||||
'type' => 'text',
|
||||
'label' => 'WC version product was created on',
|
||||
),
|
||||
array(
|
||||
'key' => '_wc_average_rating',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Average rating',
|
||||
),
|
||||
array(
|
||||
'key' => '_wc_review_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'Review count',
|
||||
),
|
||||
array(
|
||||
'key' => 'total_sales',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Total sales count',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress listing core meta keys (post_type=hp_listing).
|
||||
* Aligns with the existing wpdo_hot_hp_listing flat table; v2.9.5 will
|
||||
* copy-then-cutover the 230 dev10 rows to this group's table.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_listing_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_listing_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Listing price',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_status',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'publish', 'draft', 'pending', 'expired', 'private' ),
|
||||
'label' => 'Listing status',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_featured',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Featured flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_verified',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Verified flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_vendor',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Vendor user ID',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_expired_time',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Expiry unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_featured_time',
|
||||
'type' => 'integer',
|
||||
'label' => 'Featured-until unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_view_count',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'View counter',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Average rating',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'Rating count',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_hourly_rate',
|
||||
'type' => 'decimal',
|
||||
'label' => 'Hourly rate',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress request core meta keys (post_type=hp_request).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_request_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_request_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_status',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'publish', 'draft', 'pending', 'expired' ),
|
||||
'label' => 'Request status',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_user',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Request author user ID',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_expired_time',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Expiry unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_budget',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Budget amount',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_view_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'View counter',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress vendor core meta keys (post_type=hp_vendor).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_vendor_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_vendor_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_user',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Vendor user ID (linked WP user)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_verified',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Verified flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_hourly_rate',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Hourly rate',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'Rating count',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Average rating',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nav menu item meta keys (post_type=nav_menu_item).
|
||||
*
|
||||
|
||||
@@ -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' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Array_Literal_Repair — finds flat text / textarea values stored as the
|
||||
* literal "Array" (pre-1.1.0 type caster cast arrays with (string)) and
|
||||
* rewrites them from native meta.
|
||||
*
|
||||
* Native rows that are themselves "Array" (written back by an earlier
|
||||
* bridge-restore-native) cannot be repaired here: they are only listed, to be
|
||||
* restored from a backup.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery -- table / column names come from the registry and adapter; values are prepared.
|
||||
|
||||
/**
|
||||
* Repairs "Array" literals in flat text / textarea columns.
|
||||
*/
|
||||
final class TMDO_Array_Literal_Repair {
|
||||
|
||||
/** What (string) array produced. */
|
||||
private const LITERAL = 'Array';
|
||||
|
||||
/**
|
||||
* Scan (and optionally repair) one entity type.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param bool $apply Write repairs; false = report only.
|
||||
* @return array{found:int, repairable:int, repaired:int, unrepairable:array<int, array{group:string, key:string, id:int, reason:string}>, native_polluted:array<int, array{group:string, key:string, id:int}>}
|
||||
*/
|
||||
public static function run( string $entity_type, bool $apply ): array {
|
||||
global $wpdb;
|
||||
|
||||
$report = array(
|
||||
'found' => 0,
|
||||
'repairable' => 0,
|
||||
'repaired' => 0,
|
||||
'unrepairable' => array(),
|
||||
'native_polluted' => array(),
|
||||
);
|
||||
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
if ( ! $adapter ) {
|
||||
return $report;
|
||||
}
|
||||
$meta_table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$meta_pk = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
|
||||
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
|
||||
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
|
||||
if ( ! in_array( $field['type'] ?? 'text', array( 'text', 'textarea' ), true ) ) {
|
||||
continue;
|
||||
}
|
||||
$key = (string) $field['key'];
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||
|
||||
$ids = $wpdb->get_col( $wpdb->prepare( "SELECT `{$id_col}` FROM `{$table}` WHERE `{$col}` = %s", self::LITERAL ) );
|
||||
foreach ( $ids as $id ) {
|
||||
$id = (int) $id;
|
||||
++$report['found'];
|
||||
|
||||
$native = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$meta_table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$meta_pk}` LIMIT 1", $id, $key ) );
|
||||
$reason = null === $native ? 'no_native_row' : ( self::LITERAL === $native ? 'native_is_array_literal' : '' );
|
||||
if ( '' !== $reason ) {
|
||||
$report['unrepairable'][] = array(
|
||||
'group' => $group,
|
||||
'key' => $key,
|
||||
'id' => $id,
|
||||
'reason' => $reason,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
++$report['repairable'];
|
||||
if ( $apply ) {
|
||||
$value = TMDO_Type_Caster::to_db( TMDO_Safe_Unserialize::run( $native ), $field + array( 'group' => $group ) );
|
||||
$wpdb->update( $table, array( $col => $value ), array( $id_col => $id ) );
|
||||
++$report['repaired'];
|
||||
}
|
||||
}
|
||||
|
||||
$polluted = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT `{$id_col}` FROM `{$meta_table}` WHERE meta_key = %s AND meta_value = %s", $key, self::LITERAL ) );
|
||||
foreach ( $polluted as $id ) {
|
||||
$report['native_polluted'][] = array(
|
||||
'group' => $group,
|
||||
'key' => $key,
|
||||
'id' => (int) $id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( $report['repaired'] > 0 ) {
|
||||
TMDO_Cache_Orchestrator::flush_entity( $entity_type );
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Native_Restorer — write flat-table values back to native meta.
|
||||
*
|
||||
* The only way out of aeav_only: in that mode the flat tables are the sole
|
||||
* copy of every managed key, and demoting the entity (or uninstalling) would
|
||||
* leave reads on native meta that no longer has the data.
|
||||
*
|
||||
* Rule: afterwards native meta returns what reads return today.
|
||||
* - A key is written from the group that currently owns it for that entity
|
||||
* (TMDO_Entity_Registry::get_field(), by post type for posts), so stale
|
||||
* copies in other group tables never win.
|
||||
* - Non-NULL values are stored the way WP stores them (maybe_serialize of
|
||||
* the value Type_Caster returns); a NULL value deletes the native row.
|
||||
* - Entities with no row in the owning group's table are not touched:
|
||||
* leftover native rows for them are outside the flat data.
|
||||
* - A flat value equal to its field default is not written for an entity
|
||||
* that has no native row (e.g. activity.session_count = 0): that would
|
||||
* invent native meta the entity never had.
|
||||
*
|
||||
* Policy (which side wins where both hold a value):
|
||||
* - overwrite — flat wins; the default in aeav_only, where writes only
|
||||
* reached the flat tables and native is stale.
|
||||
* - only_missing — native wins, flat only fills gaps; the default in every
|
||||
* other mode, where native is the source of truth (and may
|
||||
* be newer, e.g. WooCommerce stock written with direct SQL).
|
||||
*
|
||||
* Batched by entity id with a cursor option so an interrupted run resumes; a
|
||||
* final pass re-copies rows updated since the first run started. With
|
||||
* $demote the entity then moves to dual_write, which keeps both copies in
|
||||
* step from then on.
|
||||
*
|
||||
* A single group can be written back on its own ($group) and then retired:
|
||||
* retire() forgets its registry_meta row and keeps the flat table under a
|
||||
* `_retired_<Ymd>` name, so orphan restore never routes its keys again.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- table/column names come from the entity registry / adapter; placeholders are built for spread params.
|
||||
|
||||
/**
|
||||
* Copies flat-table values of one entity type back into its native meta table.
|
||||
*/
|
||||
final class TMDO_Native_Restorer {
|
||||
|
||||
/** Option: "entity/group" → last entity id written. */
|
||||
public const CURSOR_OPTION = 'tmdo_restore_native_cursor';
|
||||
|
||||
/** Option: entity → DB time its first (possibly interrupted) run started. */
|
||||
public const STARTED_OPTION = 'tmdo_restore_native_started';
|
||||
|
||||
/**
|
||||
* Restore one entity type (or, with $dry_run, count the rows involved).
|
||||
*
|
||||
* @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
|
||||
* @param bool $dry_run Count only; write nothing.
|
||||
* @param int $batch Rows per batch.
|
||||
* @param bool $demote After a complete run, switch the entity to dual_write.
|
||||
* @param string $group Only this group ('' = every group).
|
||||
* @param string $policy 'overwrite' | 'only_missing' | '' (by the entity's mode).
|
||||
* @return array{groups: array<string, array{rows:int, written:int, missing:int, differs:int, same:int, default_skipped:int}>, demoted: bool, policy: string}
|
||||
*/
|
||||
public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '', string $policy = '' ): array {
|
||||
global $wpdb;
|
||||
|
||||
if ( ! in_array( $policy, array( 'overwrite', 'only_missing' ), true ) ) {
|
||||
$policy = TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ? 'overwrite' : 'only_missing';
|
||||
}
|
||||
$report = array(
|
||||
'groups' => array(),
|
||||
'demoted' => false,
|
||||
'policy' => $policy,
|
||||
);
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
if ( ! $adapter ) {
|
||||
return $report;
|
||||
}
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$batch = max( 1, $batch );
|
||||
|
||||
$started = get_option( self::STARTED_OPTION, array() );
|
||||
$started = is_array( $started ) ? $started : array();
|
||||
if ( ! $dry_run && ! isset( $started[ $entity_type ] ) ) {
|
||||
$started[ $entity_type ] = (string) $wpdb->get_var( 'SELECT NOW()' );
|
||||
update_option( self::STARTED_OPTION, $started, false );
|
||||
}
|
||||
$cursor = get_option( self::CURSOR_OPTION, array() );
|
||||
$cursor = is_array( $cursor ) ? $cursor : array();
|
||||
|
||||
$tables = self::group_tables( $entity_type );
|
||||
if ( '' !== $group ) {
|
||||
$tables = array_intersect_key( $tables, array( $group => true ) );
|
||||
}
|
||||
foreach ( $tables as $group => $table ) {
|
||||
$entry = array(
|
||||
'rows' => 0,
|
||||
'written' => 0,
|
||||
'missing' => 0,
|
||||
'differs' => 0,
|
||||
'same' => 0,
|
||||
'default_skipped' => 0,
|
||||
);
|
||||
$key = "{$entity_type}/{$group}";
|
||||
$last = $dry_run ? 0 : (int) ( $cursor[ $key ] ?? 0 );
|
||||
do {
|
||||
$rows = self::fetch( $entity_type, $table, $id_col, "t.`{$id_col}` > %d ORDER BY t.`{$id_col}` LIMIT %d", array( $last, $batch ) );
|
||||
$fetched = count( $rows );
|
||||
$entry['rows'] += $fetched;
|
||||
if ( $fetched ) {
|
||||
$last = (int) end( $rows )[ $id_col ];
|
||||
foreach ( self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, ! $dry_run ) as $count_key => $n ) {
|
||||
$entry[ $count_key ] += $n;
|
||||
}
|
||||
if ( ! $dry_run ) {
|
||||
$cursor[ $key ] = $last;
|
||||
update_option( self::CURSOR_OPTION, $cursor, false );
|
||||
}
|
||||
}
|
||||
} while ( $fetched === $batch );
|
||||
|
||||
$report['groups'][ $group ] = $entry;
|
||||
}
|
||||
|
||||
if ( $dry_run ) {
|
||||
return $report;
|
||||
}
|
||||
|
||||
// Catch-up: writes that landed in the flat tables after a batch passed their row.
|
||||
foreach ( $tables as $group => $table ) {
|
||||
$rows = self::fetch( $entity_type, $table, $id_col, 't.updated_at >= %s', array( $started[ $entity_type ] ) );
|
||||
self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, true );
|
||||
}
|
||||
|
||||
foreach ( array_keys( $tables ) as $group ) {
|
||||
unset( $cursor[ "{$entity_type}/{$group}" ] );
|
||||
}
|
||||
unset( $started[ $entity_type ] );
|
||||
$cursor ? update_option( self::CURSOR_OPTION, $cursor, false ) : delete_option( self::CURSOR_OPTION );
|
||||
$started ? update_option( self::STARTED_OPTION, $started, false ) : delete_option( self::STARTED_OPTION );
|
||||
|
||||
if ( $demote ) {
|
||||
$report['demoted'] = true === TMDO_Mode_Manager::set( $entity_type, TMDO_Mode_Manager::MODE_DUAL_WRITE );
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget a group that no plugin registers any more, once native meta holds
|
||||
* every non-NULL value of its flat table (run() with $group first).
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group Group name.
|
||||
* @return array{status:string, mismatches:int, archived_table:string}
|
||||
* status: retired | registered (a plugin still registers it) |
|
||||
* missing (no registry_meta row or flat table) | mismatch (native differs).
|
||||
*/
|
||||
public static function retire( string $entity_type, string $group ): array {
|
||||
global $wpdb;
|
||||
$result = array(
|
||||
'status' => 'missing',
|
||||
'mismatches' => 0,
|
||||
'archived_table' => '',
|
||||
);
|
||||
$restored = array_map( static fn( $r ) => $r['entity_type'] . '/' . $r['group'], TMDO_Entity_Registry::get_restored_groups() );
|
||||
if ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) && ! in_array( "{$entity_type}/{$group}", $restored, true ) ) {
|
||||
$result['status'] = 'registered';
|
||||
return $result;
|
||||
}
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||
$meta_table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
|
||||
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||
$defs = $wpdb->get_var( $wpdb->prepare( "SELECT field_definitions FROM `{$meta_table}` WHERE entity_type = %s AND group_name = %s", $entity_type, $group ) );
|
||||
$fields = json_decode( (string) $defs, true );
|
||||
if ( ! $adapter || ! is_array( $fields ) || null === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$native_table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
foreach ( (array) $wpdb->get_results( "SELECT * FROM `{$table}`", ARRAY_A ) as $row ) {
|
||||
foreach ( $fields as $field ) {
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( (string) ( $field['key'] ?? '' ) );
|
||||
if ( '' === $col || ! isset( $row[ $col ] ) ) {
|
||||
continue;
|
||||
}
|
||||
$want = (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) );
|
||||
// get_results(), not get_col(): wpdb reads an empty string as null.
|
||||
$native = array_column( (array) $wpdb->get_results( $wpdb->prepare( "SELECT meta_value FROM `{$native_table}` WHERE `{$id_col}` = %d AND meta_key = %s", (int) $row[ $id_col ], $field['key'] ), ARRAY_A ), 'meta_value' );
|
||||
if ( array( $want ) !== $native ) {
|
||||
++$result['mismatches'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( $result['mismatches'] ) {
|
||||
$result['status'] = 'mismatch';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$archived = $table . '_retired_' . gmdate( 'Ymd' );
|
||||
$wpdb->query( "RENAME TABLE `{$table}` TO `{$archived}`" );
|
||||
$wpdb->delete(
|
||||
$meta_table,
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'group_name' => $group,
|
||||
)
|
||||
);
|
||||
delete_transient( 'tmdo_orphan_native_keys' );
|
||||
TMDO_Schema_Manager::flush_table_exists_cache();
|
||||
|
||||
$result['status'] = 'retired';
|
||||
$result['archived_table'] = $archived;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered groups of the entity whose flat table exists.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @return array<string, string> Group → table name.
|
||||
*/
|
||||
private static function group_tables( string $entity_type ): array {
|
||||
$tables = array();
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
|
||||
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
$tables[ $group ] = $table;
|
||||
}
|
||||
}
|
||||
return $tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat rows matching $where; post rows carry their post type as __post_type.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $table Flat table.
|
||||
* @param string $id_col Entity id column.
|
||||
* @param string $where WHERE clause with placeholders.
|
||||
* @param array<int, mixed> $params Placeholder values.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function fetch( string $entity_type, string $table, string $id_col, string $where, array $params ): array {
|
||||
global $wpdb;
|
||||
$select = 'post' === $entity_type
|
||||
? "SELECT t.*, p.post_type AS __post_type FROM `{$table}` t LEFT JOIN `{$wpdb->posts}` p ON p.ID = t.`{$id_col}`"
|
||||
: "SELECT t.* FROM `{$table}` t";
|
||||
|
||||
return (array) $wpdb->get_results( $wpdb->prepare( "{$select} WHERE {$where}", ...$params ), ARRAY_A );
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the native meta of the keys this group owns with the flat rows,
|
||||
* and (when $write) bring native in line according to $policy.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group Group the rows come from.
|
||||
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||
* @param array<int, array<string, mixed>> $rows Flat rows.
|
||||
* @param string $policy 'overwrite' | 'only_missing'.
|
||||
* @param bool $write False = classify only (dry run).
|
||||
* @return array{written:int, missing:int, differs:int, same:int, default_skipped:int}
|
||||
*/
|
||||
private static function apply_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows, string $policy, bool $write ): array {
|
||||
$counts = array(
|
||||
'written' => 0,
|
||||
'missing' => 0,
|
||||
'differs' => 0,
|
||||
'same' => 0,
|
||||
'default_skipped' => 0,
|
||||
);
|
||||
list( $wanted, $fields ) = self::flat_values( $entity_type, $group, $adapter, $rows );
|
||||
if ( empty( $wanted ) ) {
|
||||
return $counts;
|
||||
}
|
||||
$native = self::native_values( $entity_type, $adapter, array_keys( $wanted ), array_keys( $fields ) );
|
||||
|
||||
$deletes = array();
|
||||
$inserts = array();
|
||||
foreach ( $wanted as $id => $values ) {
|
||||
foreach ( $values as $key => $value ) {
|
||||
$has = isset( $native[ $id ] ) && array_key_exists( $key, $native[ $id ] );
|
||||
if ( null === $value ) {
|
||||
if ( $has ) {
|
||||
++$counts['differs'];
|
||||
if ( 'overwrite' === $policy ) {
|
||||
$deletes[ $key ][] = $id;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ( ! $has ) {
|
||||
if ( self::is_default( $value, $fields[ $key ] ) ) {
|
||||
++$counts['default_skipped'];
|
||||
continue;
|
||||
}
|
||||
++$counts['missing'];
|
||||
$inserts[] = array( $id, $key, $value );
|
||||
continue;
|
||||
}
|
||||
if ( self::same_value( $native[ $id ][ $key ], $value ) ) {
|
||||
++$counts['same'];
|
||||
continue;
|
||||
}
|
||||
++$counts['differs'];
|
||||
if ( 'overwrite' === $policy ) {
|
||||
$deletes[ $key ][] = $id;
|
||||
$inserts[] = array( $id, $key, $value );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( $write ) {
|
||||
self::write_native( $entity_type, $adapter, $deletes, $inserts, array_keys( $wanted ) );
|
||||
$counts['written'] = count( $inserts );
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Values this group would write to native, per entity and key (NULL = none),
|
||||
* restricted to the keys the group owns for that entity.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param string $group Group name.
|
||||
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||
* @param array<int, array<string, mixed>> $rows Flat rows.
|
||||
* @return array{0: array<int, array<string, string|null>>, 1: array<string, array<string, mixed>>}
|
||||
*/
|
||||
private static function flat_values( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): array {
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$wanted = array();
|
||||
$fields = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$id = (int) $row[ $id_col ];
|
||||
$post_type = 'post' === $entity_type ? (string) ( $row['__post_type'] ?? '' ) : '';
|
||||
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
|
||||
$owner = TMDO_Entity_Registry::get_field( $entity_type, $field['key'], $post_type );
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
||||
if ( null === $owner || $owner['group'] !== $group || ! array_key_exists( $col, $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$fields[ $field['key'] ] = $field;
|
||||
$wanted[ $id ][ $field['key'] ] = null === $row[ $col ]
|
||||
? null
|
||||
: (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) );
|
||||
}
|
||||
}
|
||||
return array( $wanted, $fields );
|
||||
}
|
||||
|
||||
/**
|
||||
* First native value per entity and key.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||
* @param int[] $ids Entity IDs.
|
||||
* @param string[] $keys Meta keys.
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private static function native_values( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $ids, array $keys ): array {
|
||||
global $wpdb;
|
||||
$meta_table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$pk = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
|
||||
$in_ids = implode( ',', array_map( 'intval', $ids ) );
|
||||
$in_keys = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
||||
$rows = (array) $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key, meta_value FROM `{$meta_table}` WHERE `{$id_col}` IN ({$in_ids}) AND meta_key IN ({$in_keys}) ORDER BY `{$pk}`", ...$keys ),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$native = array();
|
||||
foreach ( $rows as $r ) {
|
||||
if ( ! isset( $native[ (int) $r['id'] ][ $r['meta_key'] ] ) ) {
|
||||
$native[ (int) $r['id'] ][ $r['meta_key'] ] = (string) $r['meta_value'];
|
||||
}
|
||||
}
|
||||
return $native;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete / insert native rows and clear the entities' meta caches.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||
* @param array<string, int[]> $deletes Meta key → entity IDs to clear first.
|
||||
* @param array<int, array{0:int, 1:string, 2:string}> $inserts Rows to insert.
|
||||
* @param int[] $ids Entities touched.
|
||||
* @return void
|
||||
*/
|
||||
private static function write_native( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $deletes, array $inserts, array $ids ): void {
|
||||
global $wpdb;
|
||||
$meta_table = $adapter->get_native_meta_table();
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
|
||||
foreach ( $deletes as $meta_key => $del_ids ) {
|
||||
$in = implode( ',', array_map( 'intval', $del_ids ) );
|
||||
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$meta_table}` WHERE meta_key = %s AND `{$id_col}` IN ({$in})", $meta_key ) );
|
||||
}
|
||||
if ( $inserts ) {
|
||||
$values = implode( ', ', array_fill( 0, count( $inserts ), '(%d, %s, %s)' ) );
|
||||
$wpdb->query( $wpdb->prepare( "INSERT INTO `{$meta_table}` (`{$id_col}`, meta_key, meta_value) VALUES {$values}", ...array_merge( ...$inserts ) ) );
|
||||
}
|
||||
foreach ( $ids as $id ) {
|
||||
wp_cache_delete( $id, "{$entity_type}_meta" );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stored value (as WP would store it) equals the field default.
|
||||
*
|
||||
* @param string $value Value as it would be stored.
|
||||
* @param array<string, mixed> $field Field definition.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_default( string $value, array $field ): bool {
|
||||
return isset( $field['default'] ) && (string) maybe_serialize( $field['default'] ) === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native and candidate values are equal (numeric-aware: '3' equals '3.000000').
|
||||
*
|
||||
* @param string $native Native meta value.
|
||||
* @param string $candidate Value from the flat table.
|
||||
* @return bool
|
||||
*/
|
||||
private static function same_value( string $native, string $candidate ): bool {
|
||||
return $native === $candidate
|
||||
|| ( is_numeric( $native ) && is_numeric( $candidate ) && (float) $native === (float) $candidate );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Post_Group_Reconciler — move post values written under legacy routing.
|
||||
*
|
||||
* Before post_type-scoped routing (tmdo_post_scoped_routing), a meta key
|
||||
* shared by several post groups was read and written through the last group
|
||||
* registered, e.g. a listing's hp_rating lived in wp_wpdo_post_hp_vendor_core.
|
||||
* Scoped lookups read it from the listing's own group instead, so those
|
||||
* values have to be copied there first.
|
||||
*
|
||||
* Rule: after the switch every read must return what it returned before.
|
||||
* - A non-NULL value in the legacy owner's table is what reads served, so
|
||||
* it overwrites the scoped group's column.
|
||||
* - NULL / missing legacy values leave the scoped column alone (it holds
|
||||
* the backfill copy, the only data there is).
|
||||
* - Source rows are left in place; scoped lookups never read them again.
|
||||
*
|
||||
* Batched by entity id with a cursor option so an interrupted run resumes; a
|
||||
* final pass re-copies legacy rows updated since the first run started, then
|
||||
* scoped routing is switched on.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- placeholders are built for spread params; table/column names come from the entity registry via Schema_Manager and are sanitized there.
|
||||
|
||||
/**
|
||||
* Copies legacy-routed post values into their post type's group table.
|
||||
*/
|
||||
final class TMDO_Post_Group_Reconciler {
|
||||
|
||||
/** Option: move id → last entity id copied. */
|
||||
public const CURSOR_OPTION = 'tmdo_post_reconcile_cursor';
|
||||
|
||||
/** Option: DB time the first (possibly interrupted) run started. */
|
||||
public const STARTED_OPTION = 'tmdo_post_reconcile_started';
|
||||
|
||||
/**
|
||||
* Keys of scoped post groups whose legacy owner is another group.
|
||||
*
|
||||
* @return array<int, array{target:string, source:string, key:string, post_types:string[]}>
|
||||
*/
|
||||
public static function plan(): array {
|
||||
$moves = array();
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||||
$post_types = TMDO_Entity_Registry::get_group_post_types( 'post', $group );
|
||||
if ( empty( $post_types ) ) {
|
||||
continue;
|
||||
}
|
||||
foreach ( TMDO_Entity_Registry::get_group_keys( 'post', $group ) as $key ) {
|
||||
$legacy = TMDO_Entity_Registry::get_field( 'post', $key );
|
||||
if ( null === $legacy || $legacy['group'] === $group ) {
|
||||
continue;
|
||||
}
|
||||
$moves[] = array(
|
||||
'target' => $group,
|
||||
'source' => $legacy['group'],
|
||||
'key' => $key,
|
||||
'post_types' => $post_types,
|
||||
);
|
||||
}
|
||||
}
|
||||
return $moves;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the values (or, with $dry_run, count what would be copied).
|
||||
*
|
||||
* @param bool $dry_run Count only; write nothing.
|
||||
* @param int $batch Rows per batch.
|
||||
* @return array{moves: array<int, array<string, mixed>>, enabled: bool}
|
||||
*/
|
||||
public static function run( bool $dry_run = false, int $batch = 500 ): array {
|
||||
global $wpdb;
|
||||
|
||||
$report = array(
|
||||
'moves' => array(),
|
||||
'enabled' => false,
|
||||
);
|
||||
$adapter = TMDO_Entity_Registry::get_adapter( 'post' );
|
||||
if ( ! $adapter ) {
|
||||
return $report;
|
||||
}
|
||||
$id_col = $adapter->get_entity_id_column();
|
||||
$batch = max( 1, $batch );
|
||||
|
||||
if ( ! $dry_run && false === get_option( self::STARTED_OPTION ) ) {
|
||||
update_option( self::STARTED_OPTION, (string) $wpdb->get_var( 'SELECT NOW()' ), false );
|
||||
}
|
||||
$cursor = get_option( self::CURSOR_OPTION, array() );
|
||||
$cursor = is_array( $cursor ) ? $cursor : array();
|
||||
|
||||
$moves = self::plan();
|
||||
foreach ( $moves as $move ) {
|
||||
list( $from, $params ) = self::source_clause( $move, $id_col );
|
||||
$entry = array(
|
||||
'target' => $move['target'],
|
||||
'source' => $move['source'],
|
||||
'key' => $move['key'],
|
||||
'copied' => 0,
|
||||
'overwritten' => 0,
|
||||
);
|
||||
|
||||
if ( $dry_run ) {
|
||||
$target = TMDO_Schema_Manager::get_table_name( 'post', $move['target'] );
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] );
|
||||
|
||||
$entry['copied'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) {$from}", ...$params ) );
|
||||
$entry['overwritten'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) {$from} AND EXISTS ( SELECT 1 FROM `{$target}` t WHERE t.`{$id_col}` = s.`{$id_col}` AND t.`{$col}` IS NOT NULL AND t.`{$col}` <> s.`{$col}` )",
|
||||
...$params
|
||||
)
|
||||
);
|
||||
$report['moves'][] = $entry;
|
||||
continue;
|
||||
}
|
||||
|
||||
$move_id = "{$move['source']}>{$move['target']}:{$move['key']}";
|
||||
$last = (int) ( $cursor[ $move_id ] ?? 0 );
|
||||
do {
|
||||
$rows = (array) $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT s.`{$id_col}` AS id, s.`" . TMDO_Schema_Manager::sanitize_column_name( $move['key'] ) . "` AS v {$from} AND s.`{$id_col}` > %d ORDER BY s.`{$id_col}` LIMIT %d", ...array_merge( $params, array( $last, $batch ) ) ),
|
||||
ARRAY_A
|
||||
);
|
||||
$entry['copied'] += self::copy_rows( $move, $id_col, $rows );
|
||||
$fetched = count( $rows );
|
||||
if ( $fetched ) {
|
||||
$last = (int) end( $rows )['id'];
|
||||
$cursor[ $move_id ] = $last;
|
||||
update_option( self::CURSOR_OPTION, $cursor, false );
|
||||
}
|
||||
} while ( $fetched === $batch );
|
||||
|
||||
$report['moves'][] = $entry;
|
||||
}
|
||||
|
||||
if ( $dry_run ) {
|
||||
return $report;
|
||||
}
|
||||
|
||||
// Catch-up: legacy-routed writes that landed after a batch passed their row.
|
||||
$started = (string) get_option( self::STARTED_OPTION );
|
||||
foreach ( $moves as $move ) {
|
||||
list( $from, $params ) = self::source_clause( $move, $id_col );
|
||||
$rows = (array) $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT s.`{$id_col}` AS id, s.`" . TMDO_Schema_Manager::sanitize_column_name( $move['key'] ) . "` AS v {$from} AND s.updated_at >= %s", ...array_merge( $params, array( $started ) ) ),
|
||||
ARRAY_A
|
||||
);
|
||||
self::copy_rows( $move, $id_col, $rows );
|
||||
}
|
||||
|
||||
update_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, '1', true );
|
||||
delete_option( self::CURSOR_OPTION );
|
||||
delete_option( self::STARTED_OPTION );
|
||||
$report['enabled'] = true;
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* FROM/WHERE for non-NULL legacy values of the move's post types.
|
||||
*
|
||||
* @param array<string, mixed> $move One entry of plan().
|
||||
* @param string $id_col Entity id column of the flat tables.
|
||||
* @return array{0:string, 1:array<int, mixed>}
|
||||
*/
|
||||
private static function source_clause( array $move, string $id_col ): array {
|
||||
global $wpdb;
|
||||
$source = TMDO_Schema_Manager::get_table_name( 'post', $move['source'] );
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] );
|
||||
$types = implode( ',', array_fill( 0, count( $move['post_types'] ), '%s' ) );
|
||||
|
||||
return array(
|
||||
"FROM `{$source}` s INNER JOIN `{$wpdb->posts}` p ON p.ID = s.`{$id_col}` AND p.post_type IN ({$types}) WHERE s.`{$col}` IS NOT NULL",
|
||||
$move['post_types'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert one column of the target group rows and drop their cached copies.
|
||||
*
|
||||
* @param array<string, mixed> $move One entry of plan().
|
||||
* @param string $id_col Entity id column of the flat tables.
|
||||
* @param array<int, array<string, mixed>> $rows Source rows: list of { id, v }.
|
||||
* @return int Rows written.
|
||||
*/
|
||||
private static function copy_rows( array $move, string $id_col, array $rows ): int {
|
||||
global $wpdb;
|
||||
if ( empty( $rows ) ) {
|
||||
return 0;
|
||||
}
|
||||
$target = TMDO_Schema_Manager::get_table_name( 'post', $move['target'] );
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] );
|
||||
|
||||
$values = array();
|
||||
$params = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$values[] = '(%d, %s)';
|
||||
$params[] = (int) $row['id'];
|
||||
$params[] = (string) $row['v'];
|
||||
}
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO `{$target}` (`{$id_col}`, `{$col}`) VALUES " . implode( ', ', $values ) . " ON DUPLICATE KEY UPDATE `{$col}` = VALUES(`{$col}`)",
|
||||
...$params
|
||||
)
|
||||
);
|
||||
|
||||
if ( class_exists( 'TMDO_Cache_Orchestrator' ) ) {
|
||||
foreach ( $rows as $row ) {
|
||||
TMDO_Cache_Orchestrator::invalidate( 'post', (int) $row['id'], $move['target'] );
|
||||
}
|
||||
}
|
||||
return count( $rows );
|
||||
}
|
||||
}
|
||||
@@ -99,11 +99,12 @@ final class TMDO_Post_Migration {
|
||||
/**
|
||||
* Bulk SQL pivot for one group: select managed keys from wp_postmeta,
|
||||
* filter by post_type, pivot via MAX(CASE) GROUP BY post_id, UPSERT
|
||||
* into the group's flat table.
|
||||
* into the group's flat table. Groups with json fields go row by row
|
||||
* through backfill_group_json() instead.
|
||||
*
|
||||
* @param string $group_name Entity group name (e.g. 'wc_product').
|
||||
* @param string $group_name Entity group name (e.g. 'hp_listing_core').
|
||||
* @return array{migrated:int,group:string,post_type:string|null}
|
||||
* @throws InvalidArgumentException When group is not registered.
|
||||
* @throws InvalidArgumentException When group is not registered, or is a restored orphan group.
|
||||
* @throws RuntimeException When the pivot SQL fails.
|
||||
*/
|
||||
public static function backfill_group( string $group_name ): array {
|
||||
@@ -115,8 +116,30 @@ final class TMDO_Post_Migration {
|
||||
'Unknown post entity group: ' . esc_html( $group_name )
|
||||
);
|
||||
}
|
||||
if ( ! in_array( $group_name, self::backfillable_groups(), true ) ) {
|
||||
throw new InvalidArgumentException(
|
||||
'Orphan post group, its values live only in the flat table: ' . esc_html( $group_name )
|
||||
);
|
||||
}
|
||||
|
||||
$post_type = self::group_post_type( $group_name );
|
||||
|
||||
// json values need PHP-level unserialize → json encode, which the bulk
|
||||
// pivot below cannot do: backfill such groups row by row instead.
|
||||
if ( in_array( 'json', array_column( $fields, 'type' ), true ) ) {
|
||||
$result = self::backfill_group_json( $group_name, array( 'resume' => false ) );
|
||||
if ( ! empty( $result['error'] ) || ! empty( $result['errors'] ) ) {
|
||||
throw new RuntimeException(
|
||||
'backfill_group failed for ' . esc_html( $group_name ) . ': ' . esc_html( (string) ( $result['error'] ?? $result['errors'] . ' row error(s)' ) )
|
||||
);
|
||||
}
|
||||
return array(
|
||||
'migrated' => (int) $result['migrated'],
|
||||
'group' => $group_name,
|
||||
'post_type' => $post_type,
|
||||
);
|
||||
}
|
||||
|
||||
$post_type = self::group_post_type( $group_name );
|
||||
$flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name );
|
||||
|
||||
// Introspect target table columns so we only pivot fields that actually
|
||||
@@ -131,16 +154,10 @@ final class TMDO_Post_Migration {
|
||||
}
|
||||
|
||||
// Build column list and CASE expressions.
|
||||
// Skip json/textarea types from the bulk pivot — those need row-by-row
|
||||
// unserialize handling (deferred to v2.9.4 backfill_unserialize phase).
|
||||
$columns = array();
|
||||
$cases = array();
|
||||
$update_parts = array();
|
||||
foreach ( $fields as $field ) {
|
||||
$type = $field['type'] ?? 'text';
|
||||
if ( 'json' === $type ) {
|
||||
continue;
|
||||
}
|
||||
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
||||
if ( ! isset( $existing_cols[ $col ] ) ) {
|
||||
continue; // Column not present in this table — skip silently.
|
||||
@@ -228,6 +245,10 @@ final class TMDO_Post_Migration {
|
||||
'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only'
|
||||
);
|
||||
}
|
||||
$guarded = TMDO_Promotion_Guard::check_cleanup( self::ENTITY_TYPE );
|
||||
if ( $guarded ) {
|
||||
throw new RuntimeException( esc_html( $guarded->get_error_message() ) );
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$keys = self::get_managed_keys();
|
||||
@@ -532,6 +553,17 @@ final class TMDO_Post_Migration {
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registered post groups that can be backfilled from wp_postmeta. Orphan
|
||||
* groups restored from wpdo_registry_meta are left out: their values live
|
||||
* only in the flat table.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function backfillable_groups(): array {
|
||||
return TMDO_Entity_Migration_Engine::backfillable_groups( self::ENTITY_TYPE );
|
||||
}
|
||||
|
||||
/**
|
||||
* All meta_keys registered for any post entity group.
|
||||
*
|
||||
@@ -546,8 +578,8 @@ final class TMDO_Post_Migration {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map group name to the post_type it targets. The mapping is canonical
|
||||
* to v2.9.1 group definitions in TMDO_Post_Fields.
|
||||
* Map group name to the post_type it targets (see
|
||||
* TMDO_Post_Fields::post_type_for_group()).
|
||||
*
|
||||
* Returns null for cross-post_type groups (e.g. 'wp_core').
|
||||
*
|
||||
@@ -555,23 +587,7 @@ final class TMDO_Post_Migration {
|
||||
* @return string|null
|
||||
*/
|
||||
private static function group_post_type( string $group ): ?string {
|
||||
switch ( $group ) {
|
||||
case 'attachment':
|
||||
return 'attachment';
|
||||
case 'wc_product':
|
||||
return 'product';
|
||||
case 'hp_listing_core':
|
||||
return 'hp_listing';
|
||||
case 'hp_request_core':
|
||||
return 'hp_request';
|
||||
case 'hp_vendor_core':
|
||||
return 'hp_vendor';
|
||||
case 'nav_menu_item':
|
||||
return 'nav_menu_item';
|
||||
case 'wp_core':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return TMDO_Post_Fields::post_type_for_group( $group );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,10 @@ class TMDO_Phase_Cleanup extends TMDO_Migration_Phase_Base {
|
||||
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
|
||||
throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" );
|
||||
}
|
||||
$guarded = TMDO_Promotion_Guard::check_cleanup( $this->entity_type );
|
||||
if ( $guarded ) {
|
||||
throw new \RuntimeException( $guarded->get_error_message() );
|
||||
}
|
||||
if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) {
|
||||
throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' );
|
||||
}
|
||||
|
||||
@@ -16,9 +16,26 @@
|
||||
* Hook order:
|
||||
* 1. pre_get_posts — strip registered keys from meta_query, store in
|
||||
* $query->set( 'wpdo_post_clauses', $routed )
|
||||
* 2. posts_join — LEFT JOIN wp_wpdo_post_<group> per routed post_type
|
||||
* 2. posts_join — LEFT JOIN wp_wpdo_post_<group> per routed group
|
||||
* 3. posts_where — append WHERE conditions targeting flat columns
|
||||
* 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication
|
||||
*
|
||||
* Flat tables hold one row per post (UNIQUE post_id), so the joins never
|
||||
* multiply rows and no GROUP BY is added.
|
||||
*
|
||||
* Status / date on the flat table (O-1): when the group's post_status /
|
||||
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
|
||||
* a single-post-type search with explicit statuses and date ordering
|
||||
* INNER JOINs the table, filters the copies and orders by the copied
|
||||
* post_date, so MySQL can walk `idx_post_status_date` and stop at the
|
||||
* LIMIT. Not with NOT EXISTS: posts without a flat row must still match.
|
||||
*
|
||||
* Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every
|
||||
* match inside the page query); found_posts_query answers from a COUNT of the
|
||||
* same query, kept in the object cache until a post is saved or deleted, a
|
||||
* flat write goes through TMDO_Hook_Bus (bump_count_version()) or terms'
|
||||
* last_changed moves. Not posts' last_changed: WordPress moves it on any post
|
||||
* meta write, which themes do on page views (Blocksy's dynamic CSS), and a
|
||||
* routed search reads no unmanaged meta.
|
||||
*
|
||||
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
|
||||
* only entity_type='post'.
|
||||
@@ -43,22 +60,33 @@ class TMDO_Post_Query_Router {
|
||||
/** Query var key for capturing routed clauses (consumed by posts_join/where). */
|
||||
private const QUERY_VAR = 'wpdo_post_clauses';
|
||||
|
||||
/** Query var holding the status / date copy route, when one applies. */
|
||||
private const COPY_VAR = 'wpdo_post_copy_route';
|
||||
|
||||
/** Query var holding the flat column a routed meta orderby sorts on. */
|
||||
private const SORT_VAR = 'wpdo_post_sort';
|
||||
|
||||
/** Query var holding the COUNT for found_posts_query. */
|
||||
private const COUNT_VAR = 'wpdo_post_count_sql';
|
||||
|
||||
/** Object cache group for found-rows counts. */
|
||||
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
|
||||
|
||||
/** The last_changed group the cached counts follow (bump_count_version()). */
|
||||
public const COUNT_VERSION_GROUP = 'tmdo_posts';
|
||||
|
||||
/** Seconds a "every post of the type has a flat row" answer is reused. */
|
||||
private const COVERAGE_TTL = 900;
|
||||
|
||||
/** Comparison operators the router can express on a flat column. */
|
||||
private const COMPARES = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' );
|
||||
|
||||
/**
|
||||
* Map post_type → entity group (mirror of TMDO_Post_Migration::group_post_type
|
||||
* but inverted; a post_type may map to multiple groups eventually, currently
|
||||
* we route to the most-specific *_core group plus the cross-cutting wp_core
|
||||
* group when the key is in wp_core).
|
||||
* Per-request answer to "are this group's copies usable?".
|
||||
*
|
||||
* @var array<string,string>
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private const POST_TYPE_GROUP_MAP = array(
|
||||
'product' => 'wc_product',
|
||||
'hp_listing' => 'hp_listing_core',
|
||||
'hp_request' => 'hp_request_core',
|
||||
'hp_vendor' => 'hp_vendor_core',
|
||||
'attachment' => 'attachment',
|
||||
'nav_menu_item' => 'nav_menu_item',
|
||||
);
|
||||
private static array $copies_usable = array();
|
||||
|
||||
/**
|
||||
* Register all WP_Query hooks.
|
||||
@@ -66,10 +94,40 @@ class TMDO_Post_Query_Router {
|
||||
* @return void
|
||||
*/
|
||||
public function register_hooks(): void {
|
||||
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 );
|
||||
// Late, so meta clauses other plugins add at the default priority
|
||||
// (e.g. HivePress search filters and sorting) are already in the query.
|
||||
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 1000, 1 );
|
||||
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
|
||||
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
|
||||
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
|
||||
add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
|
||||
add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 );
|
||||
add_filter( 'split_the_query', array( $this, 'split_the_query' ), 10, 4 );
|
||||
add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 );
|
||||
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
|
||||
add_action( 'wp_insert_post', array( $this, 'add_flat_rows' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* A new post of a type with its own groups gets an empty row in each, so
|
||||
* that "every post of the type has a flat row" keeps holding and sort-only
|
||||
* searches can drive from the flat table (see flat_first()).
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param \WP_Post $post Post.
|
||||
* @param bool $update Whether an existing post was updated.
|
||||
* @return void
|
||||
*/
|
||||
public function add_flat_rows( $post_id, $post, $update ): void {
|
||||
if ( $update || ! $post instanceof \WP_Post ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
foreach ( TMDO_Post_Fields::groups_for_post_type( $post->post_type ) as $group ) {
|
||||
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||
$wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`) VALUES (%d)", (int) $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,61 +147,104 @@ class TMDO_Post_Query_Router {
|
||||
return;
|
||||
}
|
||||
|
||||
// One post type only: with several, a clause may belong to one type's
|
||||
// group and the others must still match through wp_postmeta.
|
||||
$post_types = (array) $query->get( 'post_type' );
|
||||
if ( empty( $post_types ) ) {
|
||||
if ( 1 !== count( $post_types ) ) {
|
||||
return;
|
||||
}
|
||||
$pt = (string) reset( $post_types );
|
||||
|
||||
$raw_meta_query = (array) $query->get( 'meta_query' );
|
||||
if ( empty( $raw_meta_query ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// All or nothing: routed clauses are ANDed into the WHERE, so the
|
||||
// query is routed only when every registered clause can be, and
|
||||
// the remaining (unregistered) clauses are ANDed too.
|
||||
$relation = strtoupper( (string) ( $raw_meta_query['relation'] ?? 'AND' ) );
|
||||
$orderby = $query->get( 'orderby' );
|
||||
$named = is_array( $orderby ) ? array_keys( $orderby ) : preg_split( '/[\s,]+/', trim( (string) $orderby ) );
|
||||
$sort_name = 1 === count( $named ) ? (string) reset( $named ) : '';
|
||||
$direction = 'ASC' === strtoupper( (string) ( is_array( $orderby ) ? reset( $orderby ) : $query->get( 'order' ) ) ) ? 'ASC' : 'DESC';
|
||||
$routed = array(); // Keyed by post_type.
|
||||
$remaining = array();
|
||||
$sort = array();
|
||||
|
||||
// meta_key + orderby meta_value(_num): native INNER JOINs the key
|
||||
// (EXISTS) and orders by meta_value+0 (NUMERIC) or meta_value.
|
||||
$meta_key = $query->get( 'meta_key' );
|
||||
$take_key = is_string( $meta_key ) && in_array( $sort_name, array( 'meta_value', 'meta_value_num' ), true )
|
||||
&& null !== self::field_for( array( 'key' => $meta_key ), $pt );
|
||||
if ( $take_key ) {
|
||||
$field = self::field_for( array( 'key' => $meta_key ), $pt );
|
||||
$type = 'meta_value_num' === $sort_name ? 'NUMERIC' : (string) $query->get( 'meta_type' );
|
||||
$route = self::route_clause(
|
||||
array(
|
||||
'key' => $meta_key,
|
||||
'compare' => 'EXISTS',
|
||||
'type' => $type,
|
||||
),
|
||||
$field
|
||||
);
|
||||
if ( ! $route || ! self::sortable( $route, $field ) || '' !== $query->get( 'meta_value' ) || '' !== $query->get( 'meta_compare' ) ) {
|
||||
return;
|
||||
}
|
||||
$routed[ $pt ][] = $route;
|
||||
$sort = self::sort_for( $pt, $route, $direction );
|
||||
}
|
||||
|
||||
foreach ( $raw_meta_query as $k => $clause ) {
|
||||
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
|
||||
if ( 'relation' === $k || ! is_array( $clause ) ) {
|
||||
$remaining[ $k ] = $clause;
|
||||
continue;
|
||||
}
|
||||
if ( ! isset( $clause['key'] ) ) {
|
||||
// Sort shape: matches every post, only orders by the key.
|
||||
$name = 'AND' === $relation ? self::sort_shape( $clause ) : '';
|
||||
$field = '' !== $name && $name === $sort_name ? self::field_for( $clause[ $name ], $pt ) : null;
|
||||
if ( $field ) {
|
||||
$route = self::route_clause( array( 'compare' => 'EXISTS' ) + $clause[ $name ], $field );
|
||||
if ( ! $route || ! self::sortable( $route, $field ) ) {
|
||||
return;
|
||||
}
|
||||
$sort = self::sort_for( $pt, $route, $direction );
|
||||
continue;
|
||||
}
|
||||
if ( self::has_registered_key( $clause, $pt ) ) {
|
||||
return; // Nested group.
|
||||
}
|
||||
$remaining[ $k ] = $clause;
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched = false;
|
||||
foreach ( $post_types as $pt ) {
|
||||
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'] );
|
||||
if ( ! $field ) {
|
||||
continue;
|
||||
}
|
||||
// Confirm the field's group is appropriate for this post_type:
|
||||
// either cross-cutting wp_core (any) or the post_type-specific group.
|
||||
$group = $field['group'] ?? '';
|
||||
$expected_grp = self::POST_TYPE_GROUP_MAP[ $pt ] ?? null;
|
||||
$is_appropriate = ( 'wp_core' === $group ) || ( null !== $expected_grp && $expected_grp === $group );
|
||||
if ( ! $is_appropriate ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$column = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] );
|
||||
|
||||
$routed[ $pt ][] = array(
|
||||
'group' => $group,
|
||||
'column' => $column,
|
||||
'value' => $clause['value'] ?? '',
|
||||
'compare' => strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) ),
|
||||
'type' => strtoupper( trim( (string) ( $clause['type'] ?? 'CHAR' ) ) ),
|
||||
);
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if ( ! $matched ) {
|
||||
$field = self::field_for( $clause, $pt );
|
||||
if ( ! $field ) {
|
||||
$remaining[ $k ] = $clause;
|
||||
continue;
|
||||
}
|
||||
$route = self::route_clause( $clause, $field );
|
||||
if ( ! $route || 'AND' !== $relation ) {
|
||||
return;
|
||||
}
|
||||
// Named by orderby: only as the sole ordering, on a column that
|
||||
// orders like native (otherwise the name would be lost).
|
||||
if ( is_string( $k ) && in_array( $k, $named, true ) ) {
|
||||
if ( $k !== $sort_name || ! self::sortable( $route, $field ) ) {
|
||||
return;
|
||||
}
|
||||
$sort = self::sort_for( $pt, $route, $direction );
|
||||
}
|
||||
$routed[ $pt ][] = $route;
|
||||
}
|
||||
|
||||
if ( empty( $routed ) ) {
|
||||
if ( empty( $routed ) && empty( $sort ) ) {
|
||||
return;
|
||||
}
|
||||
if ( $take_key ) {
|
||||
$query->set( 'meta_key', '' );
|
||||
}
|
||||
if ( $sort ) {
|
||||
$query->set( self::SORT_VAR, $sort );
|
||||
}
|
||||
|
||||
// Preserve relation if surviving clauses still have it.
|
||||
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
|
||||
@@ -152,10 +253,15 @@ class TMDO_Post_Query_Router {
|
||||
|
||||
$query->set( 'meta_query', $remaining );
|
||||
$query->set( self::QUERY_VAR, $routed );
|
||||
|
||||
$copy = self::copy_route( $query, $routed );
|
||||
if ( $copy ) {
|
||||
$query->set( self::COPY_VAR, $copy );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LEFT JOIN flat tables for each routed post_type.
|
||||
* LEFT JOIN the flat table of each routed group.
|
||||
*
|
||||
* @param string|null $join Current JOIN SQL.
|
||||
* @param \WP_Query $query Current WP_Query.
|
||||
@@ -164,19 +270,20 @@ class TMDO_Post_Query_Router {
|
||||
public function posts_join( ?string $join, \WP_Query $query ): string {
|
||||
$join = (string) ( $join ?? '' );
|
||||
$routed = $query->get( self::QUERY_VAR );
|
||||
if ( empty( $routed ) || ! is_array( $routed ) ) {
|
||||
return $join;
|
||||
$sort = $query->get( self::SORT_VAR );
|
||||
$pairs = array(); // Pairs of post type and group.
|
||||
foreach ( is_array( $routed ) ? $routed : array() as $post_type => $clauses ) {
|
||||
foreach ( array_column( $clauses, 'group' ) as $group ) {
|
||||
$pairs[] = array( $post_type, $group );
|
||||
}
|
||||
}
|
||||
if ( ! empty( $sort ) ) {
|
||||
$pairs[] = array( $sort['post_type'], $sort['group'] );
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
|
||||
foreach ( $routed as $post_type => $clauses ) {
|
||||
// Each routed clause carries its group; use the first clause's group
|
||||
// (all clauses for a given post_type currently target one group).
|
||||
$group = $clauses[0]['group'] ?? '';
|
||||
if ( '' === $group ) {
|
||||
continue;
|
||||
}
|
||||
foreach ( $pairs as list( $post_type, $group ) ) {
|
||||
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
|
||||
$alias = self::table_alias( $post_type, $group );
|
||||
|
||||
@@ -185,7 +292,9 @@ class TMDO_Post_Query_Router {
|
||||
continue;
|
||||
}
|
||||
|
||||
$join .= " LEFT JOIN `{$table}` AS `{$alias}` "
|
||||
// The copy route only applies to single-group searches.
|
||||
$type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT';
|
||||
$join .= " {$type} JOIN `{$table}` AS `{$alias}` "
|
||||
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
||||
}
|
||||
|
||||
@@ -219,35 +328,147 @@ class TMDO_Post_Query_Router {
|
||||
}
|
||||
}
|
||||
|
||||
$copy = $query->get( self::COPY_VAR );
|
||||
if ( ! empty( $copy ) ) {
|
||||
$alias = self::table_alias( $copy['post_type'], $copy['group'] );
|
||||
$where .= self::build_condition( "`{$alias}`.`post_status`", 'IN', $copy['statuses'] );
|
||||
}
|
||||
|
||||
return $where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
|
||||
* Order by the routed meta orderby column (ties by ID), or by the flat
|
||||
* table's post_date copy when the copy route applies.
|
||||
*
|
||||
* @param string|null $groupby Current GROUP BY SQL.
|
||||
* @param string|null $orderby Current ORDER BY SQL.
|
||||
* @param \WP_Query $query Current WP_Query.
|
||||
* @return string Modified GROUP BY SQL.
|
||||
* @return string Modified ORDER BY SQL.
|
||||
*/
|
||||
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
|
||||
$groupby = (string) ( $groupby ?? '' );
|
||||
$routed = $query->get( self::QUERY_VAR );
|
||||
if ( empty( $routed ) || ! is_array( $routed ) ) {
|
||||
return $groupby;
|
||||
public function posts_orderby( ?string $orderby, \WP_Query $query ): string {
|
||||
$orderby = (string) ( $orderby ?? '' );
|
||||
$sort = $query->get( self::SORT_VAR );
|
||||
if ( ! empty( $sort ) ) {
|
||||
global $wpdb;
|
||||
return '`' . self::table_alias( $sort['post_type'], $sort['group'] ) . '`.`' . esc_sql( $sort['column'] ) . '` '
|
||||
. $sort['order'] . ", `{$wpdb->posts}`.`ID` DESC";
|
||||
}
|
||||
$copy = $query->get( self::COPY_VAR );
|
||||
if ( empty( $copy ) ) {
|
||||
return $orderby;
|
||||
}
|
||||
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop SQL_CALC_FOUND_ROWS from a routed search; keep its COUNT for later.
|
||||
*
|
||||
* @param string|null $request Full SELECT.
|
||||
* @param \WP_Query $query Current WP_Query.
|
||||
* @return string
|
||||
*/
|
||||
public function posts_request( ?string $request, \WP_Query $query ): string {
|
||||
$request = (string) ( $request ?? '' );
|
||||
$routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) );
|
||||
if ( ! $routed ) {
|
||||
return $request;
|
||||
}
|
||||
$counted = false !== strpos( $request, 'SQL_CALC_FOUND_ROWS' );
|
||||
$request = self::routed_request( $request, $query );
|
||||
if ( ! $counted ) {
|
||||
return $request;
|
||||
}
|
||||
global $wpdb;
|
||||
if ( '' === trim( $groupby ) ) {
|
||||
$groupby = "`{$wpdb->posts}`.`ID`";
|
||||
$matches = (string) preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
|
||||
$not_in = " AND {$wpdb->posts}.ID NOT IN (";
|
||||
// post__not_in (HivePress sends a random set per page) would give every
|
||||
// search its own cache key: count without it (cached) minus the excluded
|
||||
// posts that match (primary-key lookups, so no forced flat-first order).
|
||||
if ( 1 === substr_count( $matches, $not_in ) && preg_match( '/' . preg_quote( $not_in, '/' ) . '([\d,]+)\)/', $matches, $m ) ) {
|
||||
$excluded = str_replace( $m[0], " AND {$wpdb->posts}.ID IN ({$m[1]})", (string) preg_replace( '/^SELECT STRAIGHT_JOIN /', 'SELECT ', $matches ) );
|
||||
$query->set(
|
||||
self::COUNT_VAR,
|
||||
array(
|
||||
'SELECT COUNT(*) FROM ( ' . str_replace( $m[0], '', $matches ) . ' ) AS tmdo_found',
|
||||
"SELECT COUNT(*) FROM ( {$excluded} ) AS tmdo_found",
|
||||
)
|
||||
);
|
||||
return $request;
|
||||
}
|
||||
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
|
||||
return $request;
|
||||
}
|
||||
|
||||
return $groupby;
|
||||
/**
|
||||
* Keep WordPress's split query (IDs first, then cached post objects) on a
|
||||
* routed search: posts_request only dropped SQL_CALC_FOUND_ROWS, yet
|
||||
* WordPress takes any change for a filtered request and selects full rows
|
||||
* (a temporary table sorting whole posts). The split ID request passes
|
||||
* through posts_request_ids, which drops SQL_CALC_FOUND_ROWS again.
|
||||
*
|
||||
* @param bool $split Core decision.
|
||||
* @param \WP_Query $query Current WP_Query.
|
||||
* @param string $old_request Request before posts_request.
|
||||
* @param array<string, string> $clauses Query clauses (fields, limits …).
|
||||
* @return bool
|
||||
*/
|
||||
public function split_the_query( $split, \WP_Query $query, $old_request = '', $clauses = array() ): bool {
|
||||
$routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) );
|
||||
if ( $split || ! $routed ) {
|
||||
return (bool) $split;
|
||||
}
|
||||
global $wpdb;
|
||||
// Core's other conditions: full rows, and a LIMIT under 500 or an external object cache.
|
||||
return "{$wpdb->posts}.*" === ( $clauses['fields'] ?? '' )
|
||||
&& self::routed_request( (string) $old_request, $query ) === $query->request
|
||||
&& ( ( '' !== ( $clauses['limits'] ?? '' ) && (int) $query->get( 'posts_per_page' ) < 500 ) || wp_using_ext_object_cache() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer found rows from the cached COUNT of a routed search.
|
||||
*
|
||||
* @param string|null $sql Default 'SELECT FOUND_ROWS()'.
|
||||
* @param \WP_Query $query Current WP_Query.
|
||||
* @return string
|
||||
*/
|
||||
public function found_posts_query( ?string $sql, \WP_Query $query ): string {
|
||||
$count_sql = $query->get( self::COUNT_VAR );
|
||||
if ( empty( $count_sql ) ) {
|
||||
return (string) ( $sql ?? '' );
|
||||
}
|
||||
global $wpdb;
|
||||
list( $count_sql, $excluded_sql ) = array_pad( (array) $count_sql, 2, '' );
|
||||
|
||||
// Keyword searches carry wpdb's placeholder escape for "%", which is new
|
||||
// in every request: without it the key would never match again.
|
||||
$stable = method_exists( $wpdb, 'remove_placeholder_escape' ) ? $wpdb->remove_placeholder_escape( $count_sql ) : $count_sql;
|
||||
$key = md5( $stable ) . ':' . wp_cache_get_last_changed( self::COUNT_VERSION_GROUP ) . ':' . wp_cache_get_last_changed( 'terms' );
|
||||
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
|
||||
if ( false === $total ) {
|
||||
$total = (int) $wpdb->get_var( $count_sql );
|
||||
wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS );
|
||||
}
|
||||
if ( '' !== $excluded_sql ) {
|
||||
$total = max( 0, (int) $total - (int) $wpdb->get_var( $excluded_sql ) );
|
||||
}
|
||||
return 'SELECT ' . (int) $total;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A post was saved or deleted, or a managed key written: cached routed
|
||||
* counts are stale. Hooked on clean_post_cache whatever the mode, so a
|
||||
* count cached before the router was last active is not reused.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function bump_count_version(): void {
|
||||
wp_cache_set_last_changed( self::COUNT_VERSION_GROUP );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the router is mode-active (post mode is shadow_read or aeav_only).
|
||||
*
|
||||
@@ -260,6 +481,415 @@ class TMDO_Post_Query_Router {
|
||||
return TMDO_Mode_Manager::reads_from_flat( 'post' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget per-request copy checks (tests, or after `post-status-sync`).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function flush_copy_cache(): void {
|
||||
self::$copies_usable = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every post of the type has a row in the group's flat table. One
|
||||
* anti-join per group, reused for COVERAGE_TTL and per post-mode change.
|
||||
* A post inserted by direct SQL (no wp_insert_post) is seen at the next
|
||||
* check at the latest; the `tmdo_router_flat_first` filter turns the
|
||||
* flat-first route off.
|
||||
*
|
||||
* @param string $post_type Post type.
|
||||
* @param string $group Entity group name.
|
||||
* @return bool
|
||||
*/
|
||||
private static function covers_all( string $post_type, string $group ): bool {
|
||||
$key = self::coverage_key( $post_type, $group );
|
||||
$hit = get_transient( $key );
|
||||
if ( false === $hit ) {
|
||||
global $wpdb;
|
||||
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||
$missing = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} p LEFT JOIN `{$table}` f ON f.post_id = p.ID WHERE p.post_type = %s AND f.post_id IS NULL LIMIT 1", $post_type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$hit = null === $missing ? 'yes' : 'no';
|
||||
set_transient( $key, $hit, self::COVERAGE_TTL );
|
||||
}
|
||||
return 'yes' === $hit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget every cached coverage answer (tests, or after bulk writes that
|
||||
* bypass wp_insert_post).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function forget_coverage(): void {
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||||
foreach ( TMDO_Entity_Registry::get_group_post_types( 'post', $group ) as $post_type ) {
|
||||
delete_transient( self::coverage_key( $post_type, $group ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient name of a coverage answer; a post-mode change starts a new one.
|
||||
*
|
||||
* @param string $post_type Post type.
|
||||
* @param string $group Entity group name.
|
||||
* @return string
|
||||
*/
|
||||
private static function coverage_key( string $post_type, string $group ): string {
|
||||
$entered = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
|
||||
$since = is_array( $entered ) ? (int) ( $entered['post'] ?? 0 ) : 0;
|
||||
return 'tmdo_cov_' . md5( "{$post_type}:{$group}:{$since}" );
|
||||
}
|
||||
|
||||
/**
|
||||
* The status / date copy route for a routed query, or [] when it does not apply.
|
||||
*
|
||||
* @param \WP_Query $query Current WP_Query.
|
||||
* @param array<string, array<int, array<string, mixed>>> $routed Routed clauses keyed by post type.
|
||||
* @return array{post_type:string, group:string, statuses:string[], order:string}|array{}
|
||||
*/
|
||||
private static function copy_route( \WP_Query $query, array $routed ): array {
|
||||
if ( 1 !== count( $routed ) ) {
|
||||
return array();
|
||||
}
|
||||
$post_type = (string) array_key_first( $routed );
|
||||
$groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) );
|
||||
if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true )
|
||||
|| in_array( 'NOT EXISTS', array_column( $routed[ $post_type ], 'compare' ), true ) ) {
|
||||
return array();
|
||||
}
|
||||
$statuses = $query->get( 'post_status' );
|
||||
$statuses = array_values( array_filter( array_map( 'sanitize_key', is_array( $statuses ) ? $statuses : explode( ',', (string) $statuses ) ) ) );
|
||||
if ( empty( $statuses ) || in_array( 'any', $statuses, true ) || ! self::copies_usable( $groups[0] ) ) {
|
||||
return array();
|
||||
}
|
||||
return array(
|
||||
'post_type' => $post_type,
|
||||
'group' => $groups[0],
|
||||
'statuses' => $statuses,
|
||||
'order' => 'ASC' === strtoupper( (string) $query->get( 'order' ) ) ? 'ASC' : 'DESC',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the copy route applies to this post type's join.
|
||||
*
|
||||
* @param \WP_Query $query Current WP_Query.
|
||||
* @param string $post_type Post type slug.
|
||||
* @return bool
|
||||
*/
|
||||
private static function copy_route_for( \WP_Query $query, string $post_type ): bool {
|
||||
$copy = $query->get( self::COPY_VAR );
|
||||
return ! empty( $copy ) && $copy['post_type'] === $post_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies are marked ready and no row lacks them (bulk writes that bypass
|
||||
* the Hook Bus leave NULLs until `wp tmdo post-status-sync` runs again).
|
||||
* One index probe per group per request.
|
||||
*
|
||||
* @param string $group Entity group name.
|
||||
* @return bool
|
||||
*/
|
||||
private static function copies_usable( string $group ): bool {
|
||||
if ( ! isset( self::$copies_usable[ $group ] ) ) {
|
||||
global $wpdb;
|
||||
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||
self::$copies_usable[ $group ] = class_exists( 'TMDO_Post_Status_Sync' )
|
||||
&& 'ready' === TMDO_Post_Status_Sync::state( $group )
|
||||
&& TMDO_Schema_Manager::table_exists( $table )
|
||||
&& null === $wpdb->get_var( "SELECT 1 FROM `{$table}` WHERE `post_status` IS NULL LIMIT 1" );
|
||||
}
|
||||
return self::$copies_usable[ $group ];
|
||||
}
|
||||
|
||||
/**
|
||||
* The registered field a clause targets for this post type, or null.
|
||||
*
|
||||
* @param array<string, mixed> $clause Meta query clause.
|
||||
* @param string $post_type Post type slug.
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private static function field_for( array $clause, string $post_type ): ?array {
|
||||
if ( ! is_string( $clause['key'] ?? null ) || ! in_array( $clause['compare_key'] ?? '=', array( '=', '' ), true ) ) {
|
||||
return null;
|
||||
}
|
||||
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], $post_type );
|
||||
if ( ! $field ) {
|
||||
return null;
|
||||
}
|
||||
// Either cross-cutting wp_core or one of the post type's own groups.
|
||||
$group = $field['group'] ?? '';
|
||||
return ( 'wp_core' === $group || in_array( $group, TMDO_Post_Fields::groups_for_post_type( $post_type ), true ) ) ? $field : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a nested meta query group references a registered key.
|
||||
*
|
||||
* @param array<int|string, mixed> $group Nested group.
|
||||
* @param string $post_type Post type slug.
|
||||
* @return bool
|
||||
*/
|
||||
private static function has_registered_key( array $group, string $post_type ): bool {
|
||||
foreach ( $group as $clause ) {
|
||||
if ( ! is_array( $clause ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( isset( $clause['key'] ) ? null !== self::field_for( $clause, $post_type ) : self::has_registered_key( $clause, $post_type ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The last `wp tmdo drift-check` found the group out of step with native
|
||||
* meta. A group never checked keeps routing, and so does every group in
|
||||
* aeav_only, where native is stale by design and no fallback.
|
||||
*
|
||||
* @param string $group Entity group name.
|
||||
* @return bool
|
||||
*/
|
||||
private static function drifting( string $group ): bool {
|
||||
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) {
|
||||
return false;
|
||||
}
|
||||
if ( TMDO_Schema_Manager::is_deferred( 'post', $group ) ) {
|
||||
return true; // Table upgrade waiting for WP-CLI / WP-Cron.
|
||||
}
|
||||
$status = get_option( TMDO_Drift_Check::OPTION, array() );
|
||||
$last = is_array( $status ) ? ( $status['post'][ $group ] ?? null ) : null;
|
||||
return is_array( $last ) && (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The routed form of a clause, or null when the flat column would not
|
||||
* compare the way WordPress compares the meta_value string, or when the
|
||||
* group is known to drift from native meta.
|
||||
*
|
||||
* Native casts meta_value by the clause type (CHAR when absent); flat
|
||||
* columns are typed. Routed: EXISTS / NOT EXISTS; string columns with
|
||||
* CHAR; numeric columns with a numeric type (no LIKE); integer columns
|
||||
* with CHAR =, !=, IN, NOT IN on plain integers ("10" = "10" either way).
|
||||
*
|
||||
* @param array<string, mixed> $clause Meta query clause.
|
||||
* @param array<string, mixed> $field Registered field.
|
||||
* @return array{group:string, column:string, value:mixed, compare:string, type:string}|null
|
||||
*/
|
||||
private static function route_clause( array $clause, array $field ): ?array {
|
||||
$compare = strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) );
|
||||
$type = strtoupper( trim( (string) ( $clause['type'] ?? '' ) ) );
|
||||
$type = '' === $type ? 'CHAR' : $type;
|
||||
$value = $clause['value'] ?? '';
|
||||
$kind = $field['type'] ?? 'text';
|
||||
$multi = in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true );
|
||||
|
||||
if ( ! in_array( $compare, self::COMPARES, true ) || ( ! $multi && is_array( $value ) ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$numeric_type = self::is_numeric_type( $type );
|
||||
if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
|
||||
$ok = in_array( $kind, array( 'text', 'textarea', 'enum', 'integer', 'decimal', 'boolean' ), true );
|
||||
} elseif ( in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) ) {
|
||||
$ok = 'CHAR' === $type;
|
||||
} elseif ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) && $numeric_type ) {
|
||||
$ok = ! in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true );
|
||||
} else {
|
||||
$ok = in_array( $kind, array( 'integer', 'boolean' ), true ) && 'CHAR' === $type
|
||||
&& in_array( $compare, array( '=', '!=', '<>', 'IN', 'NOT IN' ), true )
|
||||
&& array() === array_filter( (array) $value, static fn( $v ) => ! is_scalar( $v ) || (string) (int) $v !== (string) $v );
|
||||
}
|
||||
if ( ! $ok || self::drifting( (string) $field['group'] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'group' => (string) $field['group'],
|
||||
'column' => TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ),
|
||||
'value' => $value,
|
||||
'compare' => $compare,
|
||||
'type' => $type,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What posts_request makes of a routed request.
|
||||
*
|
||||
* @param string $request SELECT statement.
|
||||
* @param \WP_Query $query Current WP_Query.
|
||||
* @return string
|
||||
*/
|
||||
private static function routed_request( string $request, \WP_Query $query ): string {
|
||||
return self::flat_first( self::without_found_rows( $request ), $query );
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a filtered search ordered by a flat column from its flat table.
|
||||
*
|
||||
* MariaDB starts from wp_posts (the type / status index), looks every
|
||||
* candidate up in the flat table and sorts in a temporary table. From the
|
||||
* flat table, with the posts.ID tie-break read as the equal flat post_id,
|
||||
* it sorts one table (dev36, 100k listings: meta_value_num 263 → 147 ms,
|
||||
* HivePress sort + filter 300 → 36 ms). Only when the routed conditions
|
||||
* already require a flat row (LEFT JOIN = INNER JOIN), all on the sort
|
||||
* group, without a selective post filter; `tmdo_router_flat_first` can
|
||||
* turn it off.
|
||||
*
|
||||
* @param string $request SELECT statement.
|
||||
* @param \WP_Query $query Current WP_Query.
|
||||
* @return string
|
||||
*/
|
||||
private static function flat_first( string $request, \WP_Query $query ): string {
|
||||
$routed = $query->get( self::QUERY_VAR );
|
||||
$sort = $query->get( self::SORT_VAR );
|
||||
if ( empty( $sort ) || ! is_array( $sort ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
|
||||
return $request;
|
||||
}
|
||||
if ( empty( $routed ) || ! is_array( $routed ) ) {
|
||||
// Sort only: the LEFT JOIN keeps posts without a flat row, so the
|
||||
// flat table may drive only when every post of the type has one.
|
||||
$post_type = (string) $sort['post_type'];
|
||||
$group = (string) $sort['group'];
|
||||
if ( ! self::covers_all( $post_type, $group ) ) {
|
||||
return $request;
|
||||
}
|
||||
} else {
|
||||
$post_type = (string) array_key_first( $routed );
|
||||
$groups = array_unique( array_column( $routed[ $post_type ], 'group' ) );
|
||||
$compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) );
|
||||
if ( 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) {
|
||||
return $request;
|
||||
}
|
||||
$group = (string) $groups[0];
|
||||
}
|
||||
foreach ( array( 'p', 'page_id', 'name', 'pagename', 'attachment_id', 'post__in', 'post_parent', 'post_parent__in', 'author', 'author__in', 'author_name' ) as $var ) {
|
||||
if ( ! empty( $query->get( $var ) ) ) {
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
if ( ! apply_filters( 'tmdo_router_flat_first', true, $query ) ) {
|
||||
return $request;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
|
||||
$alias = self::table_alias( $post_type, $group );
|
||||
$on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
||||
$join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}";
|
||||
if ( 1 !== substr_count( $request, $join ) ) {
|
||||
return $request;
|
||||
}
|
||||
$flat_first = preg_replace(
|
||||
'/\bFROM\s+' . preg_quote( $wpdb->posts, '/' ) . '\b/',
|
||||
"FROM `{$table}` AS `{$alias}` INNER JOIN {$wpdb->posts} ON {$on}",
|
||||
str_replace( $join, '', $request ),
|
||||
1,
|
||||
$replaced
|
||||
);
|
||||
if ( 1 !== $replaced ) {
|
||||
return $request;
|
||||
}
|
||||
// WordPress groups by posts.ID for any tax or meta query. With the flat row
|
||||
// (one per post) as the only join there is nothing to collapse, and the
|
||||
// GROUP BY makes MySQL sort the whole match in a temporary table
|
||||
// (WooCommerce's product_visibility NOT IN on searches is a subquery).
|
||||
if ( 1 === preg_match_all( '/\bJOIN\b/i', (string) $flat_first ) ) {
|
||||
$flat_first = preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', (string) $flat_first, 1 );
|
||||
}
|
||||
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id.
|
||||
$flat_first = preg_replace_callback(
|
||||
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
|
||||
static fn( $m ) => preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)/', "`{$alias}`.`post_id`", $m[0] ),
|
||||
(string) $flat_first
|
||||
);
|
||||
return (string) preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', (string) $flat_first, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* A request without its SQL_CALC_FOUND_ROWS.
|
||||
*
|
||||
* @param string $request SELECT statement.
|
||||
* @return string
|
||||
*/
|
||||
private static function without_found_rows( string $request ): string {
|
||||
return (string) preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a meta query type casts to a number (NUMERIC, SIGNED, DECIMAL(p,s) …).
|
||||
*
|
||||
* @param string $type Upper-cased clause type.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_numeric_type( string $type ): bool {
|
||||
return 1 === preg_match( '/^(NUMERIC|SIGNED|UNSIGNED|DECIMAL)(\(\d+(,\s?\d+)?\))?$/', $type );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether ordering by the flat column matches native ordering by
|
||||
* CAST(meta_value AS type): numbers by a numeric type, strings by CHAR.
|
||||
*
|
||||
* @param array{type:string} $route Routed clause.
|
||||
* @param array<string, mixed> $field Registered field.
|
||||
* @return bool
|
||||
*/
|
||||
private static function sortable( array $route, array $field ): bool {
|
||||
$kind = $field['type'] ?? 'text';
|
||||
if ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) ) {
|
||||
return self::is_numeric_type( $route['type'] );
|
||||
}
|
||||
return in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) && 'CHAR' === $route['type'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordering a routed meta orderby resolves to.
|
||||
*
|
||||
* @param string $post_type Post type slug.
|
||||
* @param array{group:string, column:string} $route Routed clause.
|
||||
* @param string $direction ASC or DESC.
|
||||
* @return array{post_type:string, group:string, column:string, order:string}
|
||||
*/
|
||||
private static function sort_for( string $post_type, array $route, string $direction ): array {
|
||||
return array(
|
||||
'post_type' => $post_type,
|
||||
'group' => $route['group'],
|
||||
'column' => $route['column'],
|
||||
'order' => $direction,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The clause name of a "sort by a key, keep posts without it" group —
|
||||
* relation OR of exactly NOT EXISTS and EXISTS on one key and type, one
|
||||
* of them named (HivePress search sorting) — or '' for any other group.
|
||||
*
|
||||
* @param array<int|string, mixed> $group Nested meta query group.
|
||||
* @return string
|
||||
*/
|
||||
private static function sort_shape( array $group ): string {
|
||||
if ( 'OR' !== strtoupper( (string) ( $group['relation'] ?? '' ) ) ) {
|
||||
return '';
|
||||
}
|
||||
unset( $group['relation'] );
|
||||
if ( 2 !== count( $group ) ) {
|
||||
return '';
|
||||
}
|
||||
$name = '';
|
||||
$compares = array();
|
||||
foreach ( $group as $k => $clause ) {
|
||||
if ( ! is_array( $clause ) || ! is_string( $clause['key'] ?? null ) ) {
|
||||
return '';
|
||||
}
|
||||
$compares[] = strtoupper( (string) ( $clause['compare'] ?? '=' ) );
|
||||
$name = is_string( $k ) ? $k : $name;
|
||||
}
|
||||
list( $a, $b ) = array_values( $group );
|
||||
sort( $compares );
|
||||
$same = $a['key'] === $b['key'] && strtoupper( (string) ( $a['type'] ?? '' ) ) === strtoupper( (string) ( $b['type'] ?? '' ) );
|
||||
return array( 'EXISTS', 'NOT EXISTS' ) === $compares && $same ? $name : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic JOIN alias for a (post_type, group) pair.
|
||||
*
|
||||
@@ -278,9 +908,8 @@ class TMDO_Post_Query_Router {
|
||||
* @return string Sanitized operator (defaults to '=').
|
||||
*/
|
||||
private static function sanitize_compare( string $compare ): string {
|
||||
$allowed = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' );
|
||||
$compare = strtoupper( trim( $compare ) );
|
||||
return in_array( $compare, $allowed, true ) ? $compare : '=';
|
||||
return in_array( $compare, self::COMPARES, true ) ? $compare : '=';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,6 +946,10 @@ class TMDO_Post_Query_Router {
|
||||
return ' AND ' . $col_sql . ' ' . $op;
|
||||
}
|
||||
|
||||
if ( in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true ) ) {
|
||||
$value = '%' . $wpdb->esc_like( (string) $value ) . '%';
|
||||
}
|
||||
|
||||
return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ class TMDO_Snapshot_Manager {
|
||||
}
|
||||
$table = $wpdb->prefix . self::TABLE_SLUG;
|
||||
$deleted = $wpdb->delete( $table, array( 'snapshot_id' => $snapshot_id ), array( '%s' ) ); // phpcs:ignore WordPress.DB
|
||||
TMDO_Logger::info( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) );
|
||||
TMDO_Logger::notice( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) );
|
||||
return false !== $deleted;
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ class TMDO_Snapshot_Manager {
|
||||
return array();
|
||||
}
|
||||
$out = array();
|
||||
$modules = array_merge( TMDO_Feature_Flags::HPCT_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
|
||||
$modules = array_merge( TMDO_Feature_Flags::ADDON_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
|
||||
foreach ( $modules as $m ) {
|
||||
$out[ $m ] = TMDO_Feature_Flags::get( $m );
|
||||
}
|
||||
|
||||
@@ -122,12 +122,14 @@ if ( ! trait_exists( 'TMDO_Anti_EAV_Aware' ) ) {
|
||||
* @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
|
||||
* @param string $group_name Logical group name (e.g. 'tmos_vendor', 'tmos_audience').
|
||||
* @param array<int,array<string,mixed>> $fields Field definitions.
|
||||
* @param array<string,mixed> $args Optional: post_types (string[]) scopes a post group
|
||||
* to those post types (see TMDO_Entity_Registry::register_group()).
|
||||
*/
|
||||
protected function register_entity_fields( string $entity_type, string $group_name, array $fields ): bool {
|
||||
protected function register_entity_fields( string $entity_type, string $group_name, array $fields, array $args = array() ): bool {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return false;
|
||||
}
|
||||
return TMDO_Entity_Registry::register_group( $entity_type, $group_name, $fields );
|
||||
return TMDO_Entity_Registry::register_group( $entity_type, $group_name, $fields, $args );
|
||||
}
|
||||
|
||||
// ── Read/write convenience wrappers ──────────────────────────────────────
|
||||
|
||||
@@ -25,6 +25,9 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
*/
|
||||
class TMDO_Zone_Cold {
|
||||
|
||||
/** Option: post type → cold cache version (TMDO_Cache_Layer::flush_group()). */
|
||||
public const CACHE_VERSION_OPTION = 'wpdo_cold_cache_version';
|
||||
|
||||
/**
|
||||
* Get the cold table name for a post type.
|
||||
*
|
||||
@@ -45,6 +48,21 @@ class TMDO_Zone_Cold {
|
||||
return 'wpdo_cold_' . sanitize_key( $post_type );
|
||||
}
|
||||
|
||||
/**
|
||||
* Object-cache key of a post's cold blob. It carries the post type's cache
|
||||
* version once TMDO_Cache_Layer::flush_group() has bumped it (none before,
|
||||
* so existing entries keep their keys).
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $post_type Post type slug.
|
||||
* @return string
|
||||
*/
|
||||
public static function cache_key( int $post_id, string $post_type ): string {
|
||||
$versions = get_option( self::CACHE_VERSION_OPTION, array() );
|
||||
$version = is_array( $versions ) ? (int) ( $versions[ $post_type ] ?? 0 ) : 0;
|
||||
return $version ? "cold_{$post_id}_v{$version}" : "cold_{$post_id}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the cold table exists for a post type.
|
||||
*
|
||||
@@ -92,7 +110,7 @@ class TMDO_Zone_Cold {
|
||||
*/
|
||||
public static function get_blob( int $post_id, string $post_type ): array {
|
||||
$group = self::cache_group( $post_type );
|
||||
$cache_key = "cold_{$post_id}";
|
||||
$cache_key = self::cache_key( $post_id, $post_type );
|
||||
|
||||
$cached = wp_cache_get( $cache_key, $group );
|
||||
if ( false !== $cached ) {
|
||||
@@ -202,7 +220,7 @@ class TMDO_Zone_Cold {
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
|
||||
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
|
||||
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,7 +233,7 @@ class TMDO_Zone_Cold {
|
||||
public static function delete( int $post_id, string $post_type ): void {
|
||||
global $wpdb;
|
||||
$wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) );
|
||||
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
|
||||
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────
|
||||
@@ -264,7 +282,7 @@ class TMDO_Zone_Cold {
|
||||
);
|
||||
|
||||
// Invalidate object cache.
|
||||
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
|
||||
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,6 +323,6 @@ class TMDO_Zone_Cold {
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
|
||||
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
|
||||
wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,10 @@ class TMDO_Zone_Router {
|
||||
*
|
||||
* Callers only need post_id and post_type; zone enumeration and per-zone
|
||||
* error isolation are handled here. Hot and Cold are skipped when the
|
||||
* post_type has no registered fields in that zone (table may not exist).
|
||||
* post_type has no registered fields in that zone, or when the zone table
|
||||
* was never created (fields are registered long before a module runs).
|
||||
* Module state is not checked: a table left by a rolled-back module still
|
||||
* gets its rows for deleted posts removed.
|
||||
*
|
||||
* @param int $post_id Post ID being deleted.
|
||||
* @param string $post_type Post type slug.
|
||||
@@ -113,7 +116,7 @@ class TMDO_Zone_Router {
|
||||
public static function delete_post( int $post_id, string $post_type ): void {
|
||||
$registry = TMDO_Schema_Registry::instance();
|
||||
|
||||
if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) {
|
||||
if ( ! empty( $registry->get_hot_columns( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Hot::table( $post_type ) ) ) {
|
||||
try {
|
||||
TMDO_Zone_Hot::delete( $post_id, $post_type );
|
||||
} catch ( \Throwable $e ) {
|
||||
@@ -121,7 +124,7 @@ class TMDO_Zone_Router {
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) {
|
||||
if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Cold::table( $post_type ) ) ) {
|
||||
try {
|
||||
TMDO_Zone_Cold::delete( $post_id, $post_type );
|
||||
} catch ( \Throwable $e ) {
|
||||
|
||||
+40
-1
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
|
||||
Requires at least: 6.0
|
||||
Tested up to: 6.9
|
||||
Requires PHP: 8.1
|
||||
Stable tag: 1.0.0
|
||||
Stable tag: 1.3.0
|
||||
License: GPLv2 or later
|
||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
|
||||
@@ -107,6 +107,45 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.3.0 =
|
||||
* Declared flat-table indexes are added only from WP-CLI or WP-Cron, one process at a time (MySQL GET_LOCK); web requests schedule `tmdo_ensure_group_indexes`, and doctor lists indexes not added yet
|
||||
* Routed searches with post__not_in count from a cached base count minus the excluded posts; the cache key ignores wpdb's per-request placeholder escape and follows post saves and managed-key writes only, not every post meta write (HivePress keyword + price search no longer slower than native)
|
||||
* Rate-limited view requests sample their statistics (1 in 10) instead of writing an option each time
|
||||
* Logger info / notice lines only with WP_DEBUG_LOG (filter `tmdo_log_info`)
|
||||
* The public listings REST output withholds email, phone, address and IP fields even when declared public
|
||||
* Admin form posts are handled before the page renders; uninstall.php globals are prefixed
|
||||
* Existing flat tables are upgraded from WP-CLI or WP-Cron only; a group waiting for its upgrade uses native meta, then a drift fix rebuilds it
|
||||
* The index lock goes to the primary under read/write splitting; clearing the cold cache bumps a version instead of scanning the object cache group
|
||||
* Groups whose flat table was dropped no longer cause database errors on post status changes
|
||||
* Schema upgrades share one MySQL lock with index DDL; new or upgraded groups stay native until a drift fix has filled them; mode changes and snapshot deletions are always logged
|
||||
|
||||
= 1.2.0 =
|
||||
* `wp tmdo drift-check <entity> [--group] [--sample] [--fix]`: reports flat-vs-native drift per key (miss / diff / extra) and rebuilds drifted flat rows from native; native meta is never written, and aeav_only sites are refused
|
||||
* The post query router keeps a query native while one of its groups is recorded as drifting; a daily sample (500 entities per group) on the existing health-check event keeps the record current
|
||||
* Switching an entity from disabled to dual_write / shadow_read, or turning the Hook Bus back on, schedules a full drift fix 60 s later (`tmdo_drift_resync`)
|
||||
* Auto-promotion requires a clean drift check within 2 days; shadow_read diffs are capped at 20 per key per hour
|
||||
* `wp tmdo bridge-mode <entity> <mode>` (under the Promotion Guard); `member-backfill --all`; post / user backfill groups come from the registry, json groups included
|
||||
* WooCommerce: the `post/wc_product` group is no longer registered — product meta stays native (retire existing tables with `bridge-restore-native post --group=wc_product --retire`)
|
||||
* Sort-only searches drive from the flat table when every post of the type has a flat row (HivePress rating sort ~3.3 s → ~0.5–0.85 s on 100k listings)
|
||||
* `wp tmdo cleanup-hp-transients --options` removes HivePress AddOn transients left in wp_options
|
||||
* Fixed: entity backfill invented default values (0) for keys with no native row; doctor, term-comment-diagnose and bridge-status false alarms; member-cutover fatal on a refused transition
|
||||
* Admin output escaping and logging clean-ups from the 2.1 audit
|
||||
|
||||
= 1.1.1 =
|
||||
* Security: the public GET /wpdo/v1/listings endpoints returned every registered field (show_in_rest defaulted to true), including private values such as integration tokens and WooCommerce purchase notes. Fields are now private unless registered with show_in_rest => true, and protected (underscore) or credential-like keys are never exposed
|
||||
* Security: the listings endpoint no longer returns whole rows when no field is public, and only filters / sorts on public columns
|
||||
|
||||
= 1.1.0 =
|
||||
* Search-index mode for HivePress / WooCommerce sites (docs/adr-004): native meta stays the source of truth, flat tables are its search index
|
||||
* Promotion Guard: aeav_only and cleanup are refused while HivePress or WooCommerce is active (filter `tmdo_promotion_guard_allow`)
|
||||
* shadow_read reads native meta and samples a flat comparison — fixes lost native writes
|
||||
* dual_write / shadow_read: flat values follow native after every write (added_/updated_/deleted_ meta actions, delete_all, key change by meta_id)
|
||||
* Arrays in text fields are stored serialized, never "Array"; `wp tmdo repair-array-literals`
|
||||
* `wp tmdo bridge-restore-native`: overwrite in aeav_only, only-missing otherwise; no invented default rows
|
||||
* Post query router: routes only when the result equals native (OR, several post types, untyped numeric compares stay native), LIKE wildcards, one join per group, no GROUP BY, HivePress `_sort` / named / meta_value_num ordering, WordPress split query kept, flat-sorted searches driven from the flat table (filter `tmdo_router_flat_first`)
|
||||
* Deleting an entity no longer leaves an all-NULL flat row behind
|
||||
* Zone tables that were never created are never queried
|
||||
|
||||
= 1.0.0 =
|
||||
* Supersedes `wp-data-optimizer` v3.4.6 — that plugin is retired; this one is the single unified engine
|
||||
* Version numbering restarts at 1.0.0; entries below 3.4.6 are the predecessor's history, kept for archaeology
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
/**
|
||||
* Scale-benchmark data seeder (dev only — `tests/` is excluded from release packages).
|
||||
*
|
||||
* Seeds a HivePress-shaped dataset into a dedicated `*_bench` database:
|
||||
* vendors (users + hp_vendor posts) and listings (hp_listing posts), stored three
|
||||
* ways so PR-005 can compare them at 100k vendors / 1M listings:
|
||||
* 1. native — wp_posts / wp_postmeta / wp_users / wp_usermeta
|
||||
* 2. entity — wpdo_post_hp_listing_core, wpdo_post_hp_vendor_core, wpdo_user_hp_user
|
||||
* 3. hot — wpdo_hot_hp_listing (created by TMDO_Installer::create_hot_table())
|
||||
*
|
||||
* Values are derived from crc32( seed:index:key ), so every row is reproducible
|
||||
* without sequential RNG state.
|
||||
*
|
||||
* @package TMDO\Tests\Bench
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class TMDO_Bench_Seeder {
|
||||
|
||||
public const MAX_ROWS = 2000000;
|
||||
|
||||
/** 2026-01-01 00:00:00 UTC. */
|
||||
private const BASE_TS = 1767225600;
|
||||
|
||||
private const HOT_COLUMNS = array(
|
||||
'hp_price' => 'decimal(12,2) NOT NULL DEFAULT 0',
|
||||
'hp_featured' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||
'hp_verified' => 'tinyint(1) NOT NULL DEFAULT 0',
|
||||
'hp_vendor' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||
'hp_expired_time' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||
);
|
||||
|
||||
private ?mysqli $db;
|
||||
private string $prefix;
|
||||
private int $vendors;
|
||||
private int $listings;
|
||||
private int $seed;
|
||||
private int $batch;
|
||||
|
||||
/** @var array<string, array{cols: string[], rows: string[]}> Pending multi-row INSERTs per table. */
|
||||
private array $buffers = array();
|
||||
|
||||
/**
|
||||
* Refuse to run against anything that is not a dedicated bench database.
|
||||
*
|
||||
* @throws InvalidArgumentException When the name does not end in `_bench`.
|
||||
*/
|
||||
public static function assert_bench_db( string $db_name ): void {
|
||||
if ( ! preg_match( '/^[A-Za-z0-9_]+_bench$/', $db_name ) ) {
|
||||
throw new InvalidArgumentException( "Refusing to seed '{$db_name}': database name must end in _bench." );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException When a count is out of range.
|
||||
*/
|
||||
public static function assert_count( string $label, int $value ): void {
|
||||
if ( $value < 1 || $value > self::MAX_ROWS ) {
|
||||
throw new InvalidArgumentException( "--{$label} must be between 1 and " . self::MAX_ROWS . ", got {$value}." );
|
||||
}
|
||||
}
|
||||
|
||||
public function __construct( ?mysqli $db, string $prefix, int $vendors, int $listings, int $seed = 42, int $batch = 2000 ) {
|
||||
$this->db = $db;
|
||||
$this->prefix = $prefix;
|
||||
$this->vendors = $vendors;
|
||||
$this->listings = $listings;
|
||||
$this->seed = $seed;
|
||||
$this->batch = max( 1, $batch );
|
||||
}
|
||||
|
||||
// ── Deterministic generators (pure — unit tested) ───────────────────────
|
||||
|
||||
private function det( int $i, string $key, int $mod ): int {
|
||||
return crc32( "{$this->seed}:{$i}:{$key}" ) % $mod;
|
||||
}
|
||||
|
||||
/** 1-based vendor index (1..vendors) that owns listing $i. */
|
||||
public function listing_vendor( int $i ): int {
|
||||
return $this->det( $i, 'vendor', $this->vendors ) + 1;
|
||||
}
|
||||
|
||||
/** 90% publish, 5% draft, 3% pending, 2% private. */
|
||||
public function listing_status( int $i ): string {
|
||||
$r = $this->det( $i, 'status', 100 );
|
||||
if ( $r < 90 ) {
|
||||
return 'publish';
|
||||
}
|
||||
if ( $r < 95 ) {
|
||||
return 'draft';
|
||||
}
|
||||
return $r < 98 ? 'pending' : 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta a HivePress listing carries. Keys absent from the array are not stored natively.
|
||||
*
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
public function listing_meta( int $i, int $vendor_post_id ): array {
|
||||
$featured = $this->det( $i, 'featured', 100 ) < 5 ? 1 : 0;
|
||||
$meta = array(
|
||||
'hp_price' => sprintf( '%d.00', 100 + $this->det( $i, 'price', 99901 ) ),
|
||||
'hp_status' => $this->listing_status( $i ),
|
||||
'hp_featured' => $featured,
|
||||
'hp_verified' => $this->det( $i, 'verified', 100 ) < 30 ? 1 : 0,
|
||||
'hp_vendor' => $vendor_post_id,
|
||||
'hp_expired_time' => self::BASE_TS + $this->det( $i, 'expire', 365 ) * 86400,
|
||||
'hp_view_count' => $this->det( $i, 'views', 5000 ),
|
||||
'hp_rating' => sprintf( '%.2f', 1 + $this->det( $i, 'rating', 401 ) / 100 ),
|
||||
'hp_rating_count' => $this->det( $i, 'rating_count', 200 ),
|
||||
);
|
||||
if ( $featured ) {
|
||||
$meta['hp_featured_time'] = self::BASE_TS + $this->det( $i, 'featured_until', 90 ) * 86400;
|
||||
}
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/** @return array<string, int|string> */
|
||||
public function vendor_meta( int $v ): array {
|
||||
return array(
|
||||
'hp_user' => $v,
|
||||
'hp_verified' => $this->det( $v, 'v_verified', 100 ) < 40 ? 1 : 0,
|
||||
'hp_hourly_rate' => sprintf( '%d.00', 20 + $this->det( $v, 'rate', 281 ) ),
|
||||
'hp_rating' => sprintf( '%.2f', 1 + $this->det( $v, 'v_rating', 401 ) / 100 ),
|
||||
'hp_rating_count' => $this->det( $v, 'v_rating_count', 500 ),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, int|string> */
|
||||
public function user_meta( int $v ): array {
|
||||
return array(
|
||||
'first_name' => 'Vendor',
|
||||
'last_name' => (string) $v,
|
||||
'nickname' => "vendor_{$v}",
|
||||
'description' => "Bench vendor {$v}",
|
||||
'hp_verified' => $this->det( $v, 'u_verified', 100 ) < 40 ? 1 : 0,
|
||||
'hp_image' => (string) ( 100000 + $v ),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Seeding ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drop and rebuild every bench table, then load the dataset.
|
||||
*
|
||||
* @return array<string, int> Row counts per table.
|
||||
*/
|
||||
public function run(): array {
|
||||
$this->exec( 'SET SESSION unique_checks = 0' );
|
||||
$this->exec( 'SET SESSION foreign_key_checks = 0' );
|
||||
|
||||
$this->create_core_tables();
|
||||
$this->create_flat_tables();
|
||||
|
||||
$this->seed_vendors();
|
||||
$this->seed_listings();
|
||||
|
||||
$counts = array();
|
||||
foreach ( array_keys( $this->table_names() ) as $table ) {
|
||||
$counts[ $table ] = (int) $this->db->query( "SELECT COUNT(*) FROM `{$table}`" )->fetch_row()[0];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/** @return array<string, true> Every table this seeder owns. */
|
||||
private function table_names(): array {
|
||||
$p = $this->prefix;
|
||||
return array_fill_keys(
|
||||
array(
|
||||
"{$p}users",
|
||||
"{$p}usermeta",
|
||||
"{$p}posts",
|
||||
"{$p}postmeta",
|
||||
"{$p}wpdo_user_hp_user",
|
||||
"{$p}wpdo_post_hp_vendor_core",
|
||||
"{$p}wpdo_post_hp_listing_core",
|
||||
"{$p}wpdo_hot_hp_listing",
|
||||
),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core tables with the columns and indexes WordPress 6.x uses for these queries.
|
||||
*/
|
||||
private function create_core_tables(): void {
|
||||
$p = $this->prefix;
|
||||
foreach ( array( 'users', 'usermeta', 'posts', 'postmeta' ) as $t ) {
|
||||
$this->exec( "DROP TABLE IF EXISTS `{$p}{$t}`" );
|
||||
}
|
||||
$this->exec(
|
||||
"CREATE TABLE `{$p}users` (
|
||||
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
user_login varchar(60) NOT NULL DEFAULT '',
|
||||
user_nicename varchar(50) NOT NULL DEFAULT '',
|
||||
user_email varchar(100) NOT NULL DEFAULT '',
|
||||
user_registered datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
display_name varchar(250) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (ID),
|
||||
KEY user_login_key (user_login),
|
||||
KEY user_nicename (user_nicename),
|
||||
KEY user_email (user_email)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
||||
);
|
||||
$this->exec(
|
||||
"CREATE TABLE `{$p}usermeta` (
|
||||
umeta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
meta_key varchar(255) DEFAULT NULL,
|
||||
meta_value longtext,
|
||||
PRIMARY KEY (umeta_id),
|
||||
KEY user_id (user_id),
|
||||
KEY meta_key (meta_key(191))
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
||||
);
|
||||
$this->exec(
|
||||
"CREATE TABLE `{$p}posts` (
|
||||
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
post_author bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
post_date datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
post_title text NOT NULL,
|
||||
post_status varchar(20) NOT NULL DEFAULT 'publish',
|
||||
post_name varchar(200) NOT NULL DEFAULT '',
|
||||
post_parent bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
post_type varchar(20) NOT NULL DEFAULT 'post',
|
||||
PRIMARY KEY (ID),
|
||||
KEY post_name (post_name(191)),
|
||||
KEY type_status_date (post_type,post_status,post_date,ID),
|
||||
KEY post_parent (post_parent),
|
||||
KEY post_author (post_author)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
||||
);
|
||||
$this->exec(
|
||||
"CREATE TABLE `{$p}postmeta` (
|
||||
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
meta_key varchar(255) DEFAULT NULL,
|
||||
meta_value longtext,
|
||||
PRIMARY KEY (meta_id),
|
||||
KEY post_id (post_id),
|
||||
KEY meta_key (meta_key(191))
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat tables are created by TMDO's own code so the bench measures the real schema.
|
||||
*/
|
||||
private function create_flat_tables(): void {
|
||||
TMDO_Entity_Registry::init();
|
||||
TMDO_Entity_Registry::register_adapter( 'post', new TMDO_Adapter_Post() );
|
||||
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
|
||||
TMDO_Post_Fields::register_entity_fields();
|
||||
TMDO_Member_Fields::register();
|
||||
|
||||
// Schema_Manager keeps a schema hash per group in wpdo_registry_meta and skips
|
||||
// creation when it matches, so a rebuilt table needs its hash row cleared too.
|
||||
TMDO_Installer::install_v2_tables();
|
||||
$registry_meta = $this->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
|
||||
|
||||
foreach ( array( array( 'post', 'hp_listing_core' ), array( 'post', 'hp_vendor_core' ), array( 'user', 'hp_user' ) ) as [ $type, $group ] ) {
|
||||
$this->exec( 'DROP TABLE IF EXISTS `' . TMDO_Schema_Manager::get_table_name( $type, $group ) . '`' );
|
||||
$this->exec( "DELETE FROM `{$registry_meta}` WHERE entity_type = '{$type}' AND group_name = '{$group}'" );
|
||||
if ( ! TMDO_Schema_Manager::create_or_upgrade_table( $type, $group, TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) {
|
||||
throw new RuntimeException( "Failed to create entity table {$type}/{$group}." );
|
||||
}
|
||||
}
|
||||
|
||||
$this->exec( "DROP TABLE IF EXISTS `{$this->prefix}wpdo_hot_hp_listing`" );
|
||||
TMDO_Installer::create_hot_table( 'hp_listing', self::HOT_COLUMNS );
|
||||
}
|
||||
|
||||
private function seed_vendors(): void {
|
||||
$p = $this->prefix;
|
||||
for ( $v = 1; $v <= $this->vendors; $v++ ) {
|
||||
$this->push( "{$p}users", array( 'ID', 'user_login', 'user_nicename', 'user_email', 'user_registered', 'display_name' ), array( $v, "vendor_{$v}", "vendor-{$v}", "vendor{$v}@bench.invalid", '2026-01-01 00:00:00', "Vendor {$v}" ) );
|
||||
$user_meta = $this->user_meta( $v );
|
||||
foreach ( $user_meta as $key => $value ) {
|
||||
$this->push( "{$p}usermeta", array( 'user_id', 'meta_key', 'meta_value' ), array( $v, $key, $value ) );
|
||||
}
|
||||
$this->push_entity( 'user', 'hp_user', $v, $user_meta );
|
||||
|
||||
// Vendor post IDs are 1..vendors; listing post IDs follow.
|
||||
$this->push( "{$p}posts", array( 'ID', 'post_author', 'post_date', 'post_title', 'post_status', 'post_name', 'post_parent', 'post_type' ), array( $v, $v, '2026-01-01 00:00:00', "Vendor {$v}", 'publish', "vendor-{$v}", 0, 'hp_vendor' ) );
|
||||
$vendor_meta = $this->vendor_meta( $v );
|
||||
foreach ( $vendor_meta as $key => $value ) {
|
||||
$this->push( "{$p}postmeta", array( 'post_id', 'meta_key', 'meta_value' ), array( $v, $key, $value ) );
|
||||
}
|
||||
$this->push_entity( 'post', 'hp_vendor_core', $v, $vendor_meta );
|
||||
}
|
||||
$this->flush_all();
|
||||
}
|
||||
|
||||
private function seed_listings(): void {
|
||||
$p = $this->prefix;
|
||||
for ( $i = 1; $i <= $this->listings; $i++ ) {
|
||||
$post_id = $this->vendors + $i;
|
||||
$vendor_id = $this->listing_vendor( $i );
|
||||
$meta = $this->listing_meta( $i, $vendor_id );
|
||||
$date = gmdate( 'Y-m-d H:i:s', self::BASE_TS + $this->det( $i, 'date', 365 * 86400 ) );
|
||||
|
||||
$this->push( "{$p}posts", array( 'ID', 'post_author', 'post_date', 'post_title', 'post_status', 'post_name', 'post_parent', 'post_type' ), array( $post_id, $vendor_id, $date, "Listing {$i}", $meta['hp_status'], "listing-{$i}", $vendor_id, 'hp_listing' ) );
|
||||
foreach ( $meta as $key => $value ) {
|
||||
$this->push( "{$p}postmeta", array( 'post_id', 'meta_key', 'meta_value' ), array( $post_id, $key, $value ) );
|
||||
}
|
||||
$this->push_entity( 'post', 'hp_listing_core', $post_id, $meta );
|
||||
|
||||
$hot = array( 'post_id' => $post_id );
|
||||
foreach ( array_keys( self::HOT_COLUMNS ) as $col ) {
|
||||
$hot[ $col ] = $meta[ $col ] ?? 0;
|
||||
}
|
||||
$this->push( "{$p}wpdo_hot_hp_listing", array_keys( $hot ), array_values( $hot ) );
|
||||
}
|
||||
$this->flush_all();
|
||||
}
|
||||
|
||||
/**
|
||||
* One entity-table row per entity; registry keys without a value are stored as NULL.
|
||||
*
|
||||
* @param array<string, int|string> $meta
|
||||
*/
|
||||
private function push_entity( string $type, string $group, int $entity_id, array $meta ): void {
|
||||
$id_col = TMDO_Entity_Registry::get_adapter( $type )->get_entity_id_column();
|
||||
$cols = array( $id_col );
|
||||
$vals = array( $entity_id );
|
||||
foreach ( TMDO_Entity_Registry::get_group_keys( $type, $group ) as $key ) {
|
||||
$cols[] = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||
$vals[] = $meta[ $key ] ?? null;
|
||||
}
|
||||
$this->push( TMDO_Schema_Manager::get_table_name( $type, $group ), $cols, $vals );
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $cols
|
||||
* @param array<int|string|null> $vals
|
||||
*/
|
||||
private function push( string $table, array $cols, array $vals ): void {
|
||||
$escaped = array_map(
|
||||
fn( $v ) => null === $v ? 'NULL' : ( is_int( $v ) ? (string) $v : "'" . $this->db->real_escape_string( (string) $v ) . "'" ),
|
||||
$vals
|
||||
);
|
||||
$this->buffers[ $table ]['cols'] = $cols;
|
||||
$this->buffers[ $table ]['rows'][] = '(' . implode( ',', $escaped ) . ')';
|
||||
if ( count( $this->buffers[ $table ]['rows'] ) >= $this->batch ) {
|
||||
$this->flush( $table );
|
||||
}
|
||||
}
|
||||
|
||||
private function flush( string $table ): void {
|
||||
if ( empty( $this->buffers[ $table ]['rows'] ) ) {
|
||||
return;
|
||||
}
|
||||
$cols = '`' . implode( '`,`', $this->buffers[ $table ]['cols'] ) . '`';
|
||||
$this->exec( "INSERT INTO `{$table}` ({$cols}) VALUES " . implode( ',', $this->buffers[ $table ]['rows'] ) );
|
||||
$this->buffers[ $table ]['rows'] = array();
|
||||
}
|
||||
|
||||
private function flush_all(): void {
|
||||
foreach ( array_keys( $this->buffers ) as $table ) {
|
||||
$this->flush( $table );
|
||||
}
|
||||
}
|
||||
|
||||
private function exec( string $sql ): void {
|
||||
if ( ! $this->db->query( $sql ) ) {
|
||||
throw new RuntimeException( 'Bench seed query failed: ' . $this->db->error . ' — ' . substr( $sql, 0, 200 ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
/**
|
||||
* Scale benchmark: native postmeta vs hot table vs entity table.
|
||||
*
|
||||
* Runs against the dataset from tests/bench/seed.php:
|
||||
* TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/run.php [--iterations=20] [--seed=7]
|
||||
*
|
||||
* SQL mirrors what the code under test emits: WP_Query meta_query (INNER JOIN
|
||||
* per clause, CAST for numerics, SQL_CALC_FOUND_ROWS + FOUND_ROWS()),
|
||||
* TMDO_Query_Router (hot) and TMDO_Post_Query_Router (entity, %s-quoted values).
|
||||
* Writes run inside a transaction that is rolled back, so the dataset never changes.
|
||||
*
|
||||
* Output: a Markdown table on stdout (p50 / p95 / max in ms, result rows, index used).
|
||||
*
|
||||
* @package TMDO\Tests\Bench
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( 'cli' !== PHP_SAPI ) {
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/class-tmdo-bench-seeder.php';
|
||||
|
||||
const P = 'wp_itest_';
|
||||
|
||||
$opts = getopt( '', array( 'iterations::', 'seed::' ) );
|
||||
$iterations = max( 1, (int) ( $opts['iterations'] ?? 20 ) );
|
||||
$warmup = 3;
|
||||
$db_name = (string) getenv( 'TMDO_TEST_DB_NAME' );
|
||||
|
||||
try {
|
||||
TMDO_Bench_Seeder::assert_bench_db( $db_name );
|
||||
} catch ( InvalidArgumentException $e ) {
|
||||
fwrite( STDERR, $e->getMessage() . PHP_EOL );
|
||||
exit( 2 );
|
||||
}
|
||||
|
||||
$db = new mysqli(
|
||||
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
|
||||
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
|
||||
(string) getenv( 'TMDO_TEST_DB_PASS' ),
|
||||
$db_name
|
||||
);
|
||||
$db->set_charset( 'utf8mb4' );
|
||||
$db->query( 'SET SESSION max_statement_time = 30' ); // MariaDB: cap runaway native queries.
|
||||
|
||||
$vendors = (int) $db->query( 'SELECT COUNT(*) FROM `' . P . "posts` WHERE post_type = 'hp_vendor'" )->fetch_row()[0];
|
||||
$listings = (int) $db->query( 'SELECT COUNT(*) FROM `' . P . "posts` WHERE post_type = 'hp_listing'" )->fetch_row()[0];
|
||||
if ( 0 === $vendors || 0 === $listings ) {
|
||||
fwrite( STDERR, "No bench data — run tests/bench/seed.php first.\n" );
|
||||
exit( 3 );
|
||||
}
|
||||
|
||||
mt_srand( (int) ( $opts['seed'] ?? 7 ) );
|
||||
|
||||
// ── SQL builders ──────────────────────────────────────────────────────────
|
||||
|
||||
/** WP_Query shape with one INNER JOIN per meta clause. */
|
||||
function native_search( array $meta_where ): string {
|
||||
$joins = '';
|
||||
$where = array();
|
||||
foreach ( $meta_where as $n => $cond ) {
|
||||
$alias = 0 === $n ? P . 'postmeta' : "mt{$n}";
|
||||
$joins .= ' INNER JOIN `' . P . 'postmeta`' . ( 0 === $n ? '' : " AS {$alias}" ) . " ON ( p.ID = {$alias}.post_id )";
|
||||
$where[] = '( ' . sprintf( $cond, $alias ) . ' )';
|
||||
}
|
||||
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p{$joins} WHERE 1=1 AND ( " . implode( ' AND ', $where )
|
||||
. " ) AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')) GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 0, 20";
|
||||
}
|
||||
|
||||
/** TMDO router shape: one LEFT JOIN to a flat table plus flat WHERE clauses. */
|
||||
function flat_search( string $table, string $alias, string $conditions ): string {
|
||||
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p LEFT JOIN `" . P . "{$table}` AS `{$alias}` ON (p.ID = `{$alias}`.`post_id`)"
|
||||
. " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 0, 20";
|
||||
}
|
||||
|
||||
/** TMDO router shape with post_status / post_date copies (PR-029): INNER JOIN, no GROUP BY. */
|
||||
function flat_copy_search( string $table, string $alias, string $conditions ): string {
|
||||
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p INNER JOIN `" . P . "{$table}` AS `{$alias}` ON (p.ID = `{$alias}`.`post_id`)"
|
||||
. " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} AND `{$alias}`.`post_status` IN ('publish') ORDER BY `{$alias}`.`post_date` DESC LIMIT 0, 20";
|
||||
}
|
||||
|
||||
function random_ids( int $count, int $offset, int $max ): string {
|
||||
$ids = array();
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$ids[] = $offset + mt_rand( 1, $max );
|
||||
}
|
||||
return implode( ',', $ids );
|
||||
}
|
||||
|
||||
// ── Scenarios: label => [ variant => fn(): string[] (statements) ] ─────────
|
||||
|
||||
$price = static function (): array {
|
||||
$lo = mt_rand( 100, 80000 );
|
||||
return array( $lo, $lo + 20000 ); // ~20% of the 100..100000 range.
|
||||
};
|
||||
|
||||
$scenarios = array(
|
||||
'1. 價格區間搜尋(約 20%)' => array(
|
||||
'native' => function () use ( $price ) {
|
||||
[ $lo, $hi ] = $price();
|
||||
return array( native_search( array( "%1\$s.meta_key = 'hp_price' AND CAST(%1\$s.meta_value AS DECIMAL(10,2)) BETWEEN '{$lo}' AND '{$hi}'" ) ), 'SELECT FOUND_ROWS()' );
|
||||
},
|
||||
'hot' => function () use ( $price ) {
|
||||
[ $lo, $hi ] = $price();
|
||||
return array( flat_search( 'wpdo_hot_hp_listing', 'h', " AND `h`.`hp_price` BETWEEN {$lo} AND {$hi}" ), 'SELECT FOUND_ROWS()' );
|
||||
},
|
||||
'entity' => function () use ( $price ) {
|
||||
[ $lo, $hi ] = $price();
|
||||
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' );
|
||||
},
|
||||
'entity+copy' => function () use ( $price ) {
|
||||
[ $lo, $hi ] = $price();
|
||||
return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' );
|
||||
},
|
||||
),
|
||||
'2. 價格區間 + verified=1' => array(
|
||||
'native' => function () use ( $price ) {
|
||||
[ $lo, $hi ] = $price();
|
||||
return array(
|
||||
native_search(
|
||||
array(
|
||||
"%1\$s.meta_key = 'hp_price' AND CAST(%1\$s.meta_value AS DECIMAL(10,2)) BETWEEN '{$lo}' AND '{$hi}'",
|
||||
"%1\$s.meta_key = 'hp_verified' AND %1\$s.meta_value = '1'",
|
||||
)
|
||||
),
|
||||
'SELECT FOUND_ROWS()',
|
||||
);
|
||||
},
|
||||
'hot' => function () use ( $price ) {
|
||||
[ $lo, $hi ] = $price();
|
||||
return array( flat_search( 'wpdo_hot_hp_listing', 'h', " AND `h`.`hp_price` BETWEEN {$lo} AND {$hi} AND `h`.`hp_verified` = 1" ), 'SELECT FOUND_ROWS()' );
|
||||
},
|
||||
'entity' => function () use ( $price ) {
|
||||
[ $lo, $hi ] = $price();
|
||||
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' );
|
||||
},
|
||||
'entity+copy' => function () use ( $price ) {
|
||||
[ $lo, $hi ] = $price();
|
||||
return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' );
|
||||
},
|
||||
),
|
||||
'3. 某 vendor 名下 listing' => array(
|
||||
'post_parent' => fn() => array( 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p WHERE p.post_parent = " . mt_rand( 1, $vendors ) . " AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')) ORDER BY p.post_date DESC LIMIT 0, 20", 'SELECT FOUND_ROWS()' ),
|
||||
'native' => fn() => array( native_search( array( "%1\$s.meta_key = 'hp_vendor' AND %1\$s.meta_value = '" . mt_rand( 1, $vendors ) . "'" ) ), 'SELECT FOUND_ROWS()' ),
|
||||
'hot' => fn() => array( flat_search( 'wpdo_hot_hp_listing', 'h', ' AND `h`.`hp_vendor` = ' . mt_rand( 1, $vendors ) ), 'SELECT FOUND_ROWS()' ),
|
||||
'entity' => fn() => array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_vendor` = '" . mt_rand( 1, $vendors ) . "'" ), 'SELECT FOUND_ROWS()' ),
|
||||
),
|
||||
'4. 單筆 listing 讀全部 meta' => array(
|
||||
'native' => fn() => array( 'SELECT post_id, meta_key, meta_value FROM `' . P . 'postmeta` WHERE post_id IN (' . random_ids( 1, $vendors, $listings ) . ') ORDER BY meta_id ASC' ),
|
||||
'hot' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_hot_hp_listing` WHERE post_id = ' . random_ids( 1, $vendors, $listings ) ),
|
||||
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_post_hp_listing_core` WHERE post_id = ' . random_ids( 1, $vendors, $listings ) ),
|
||||
),
|
||||
'5. 列表頁預載 20 筆 meta' => array(
|
||||
'native' => fn() => array( 'SELECT post_id, meta_key, meta_value FROM `' . P . 'postmeta` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ') ORDER BY meta_id ASC' ),
|
||||
'hot' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_hot_hp_listing` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ')' ),
|
||||
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_post_hp_listing_core` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ')' ),
|
||||
),
|
||||
'6. 單一 vendor 讀使用者 meta' => array(
|
||||
'native' => fn() => array( 'SELECT user_id, meta_key, meta_value FROM `' . P . 'usermeta` WHERE user_id IN (' . mt_rand( 1, $vendors ) . ') ORDER BY umeta_id ASC' ),
|
||||
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_user_hp_user` WHERE user_id = ' . mt_rand( 1, $vendors ) ),
|
||||
),
|
||||
'7. 寫入:更新一筆 listing 價格' => array(
|
||||
'native' => fn() => array( 'UPDATE `' . P . "postmeta` SET meta_value = '" . mt_rand( 100, 100000 ) . ".00' WHERE post_id = " . random_ids( 1, $vendors, $listings ) . " AND meta_key = 'hp_price'" ),
|
||||
'entity' => fn() => array( 'UPDATE `' . P . "wpdo_post_hp_listing_core` SET hp_price = '" . mt_rand( 100, 100000 ) . "' WHERE post_id = " . random_ids( 1, $vendors, $listings ) ),
|
||||
'dual_write' => function () use ( $vendors, $listings ) {
|
||||
$id = random_ids( 1, $vendors, $listings );
|
||||
$v = mt_rand( 100, 100000 );
|
||||
return array(
|
||||
'UPDATE `' . P . "postmeta` SET meta_value = '{$v}.00' WHERE post_id = {$id} AND meta_key = 'hp_price'",
|
||||
'UPDATE `' . P . "wpdo_post_hp_listing_core` SET hp_price = '{$v}' WHERE post_id = {$id}",
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ── Run ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return array{ms: float, rows: int|string, timeout: bool}
|
||||
*/
|
||||
function run_statements( mysqli $db, array $statements ): array {
|
||||
$is_write = str_starts_with( $statements[0], 'UPDATE' );
|
||||
if ( $is_write ) {
|
||||
$db->begin_transaction();
|
||||
}
|
||||
$rows = 0;
|
||||
$timeout = false;
|
||||
$start = hrtime( true );
|
||||
foreach ( $statements as $n => $sql ) {
|
||||
try {
|
||||
$res = $db->query( $sql );
|
||||
} catch ( mysqli_sql_exception $e ) {
|
||||
if ( 1969 !== $e->getCode() ) { // ER_STATEMENT_TIMEOUT.
|
||||
throw $e;
|
||||
}
|
||||
$timeout = true;
|
||||
break;
|
||||
}
|
||||
if ( $res instanceof mysqli_result ) {
|
||||
$rows = str_starts_with( $sql, 'SELECT FOUND_ROWS' ) ? (int) $res->fetch_row()[0] : ( 0 === $n ? $res->num_rows : $rows );
|
||||
$res->free();
|
||||
} elseif ( $is_write ) {
|
||||
$rows = $db->affected_rows;
|
||||
}
|
||||
}
|
||||
$ms = ( hrtime( true ) - $start ) / 1e6;
|
||||
if ( $is_write ) {
|
||||
$db->rollback();
|
||||
}
|
||||
return array( 'ms' => $ms, 'rows' => $rows, 'timeout' => $timeout );
|
||||
}
|
||||
|
||||
function explain_keys( mysqli $db, string $sql ): string {
|
||||
if ( ! str_starts_with( $sql, 'SELECT' ) ) {
|
||||
return '—';
|
||||
}
|
||||
$keys = array();
|
||||
$res = $db->query( 'EXPLAIN ' . $sql );
|
||||
while ( $row = $res->fetch_assoc() ) {
|
||||
$keys[] = $row['table'] . ':' . ( $row['key'] ?? 'ALL' );
|
||||
}
|
||||
return implode( ', ', $keys );
|
||||
}
|
||||
|
||||
function percentile( array $sorted, float $p ): float {
|
||||
return $sorted[ (int) min( count( $sorted ) - 1, floor( $p * count( $sorted ) ) ) ];
|
||||
}
|
||||
|
||||
printf( "Dataset: %d vendors, %d listings — %d iterations (+%d warm-up) per variant\n\n", $vendors, $listings, $iterations, $warmup );
|
||||
echo "| 情境 | 形式 | p50 ms | p95 ms | max ms | 結果筆數(中位) | 使用索引 |\n|---|---|---:|---:|---:|---:|---|\n";
|
||||
|
||||
foreach ( $scenarios as $label => $variants ) {
|
||||
foreach ( $variants as $variant => $build ) {
|
||||
$times = array();
|
||||
$rows = array();
|
||||
$timeouts = 0;
|
||||
$keys = explain_keys( $db, $build()[0] );
|
||||
for ( $i = 0; $i < $warmup + $iterations; $i++ ) {
|
||||
$r = run_statements( $db, $build() );
|
||||
if ( $i < $warmup ) {
|
||||
continue;
|
||||
}
|
||||
$timeouts += (int) $r['timeout'];
|
||||
$times[] = $r['ms'];
|
||||
$rows[] = $r['rows'];
|
||||
}
|
||||
sort( $times );
|
||||
sort( $rows );
|
||||
printf(
|
||||
"| %s | %s | %.2f | %.2f | %.2f | %s | %s |\n",
|
||||
$label,
|
||||
$variant . ( $timeouts ? " ⚠ {$timeouts}× timeout" : '' ),
|
||||
percentile( $times, 0.5 ),
|
||||
percentile( $times, 0.95 ),
|
||||
end( $times ),
|
||||
$rows[ intdiv( count( $rows ), 2 ) ],
|
||||
$keys
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* Seed the scale-benchmark dataset.
|
||||
*
|
||||
* Usage (DB credentials come from the same env vars as the integration suite):
|
||||
* TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/seed.php --vendors=100000 --listings=1000000
|
||||
*
|
||||
* Options: --vendors=N (default 1000), --listings=N (default 10000), --seed=N (default 42),
|
||||
* --batch=N rows per INSERT (default 2000).
|
||||
*
|
||||
* Drops and rebuilds its own wp_itest_* tables every run. Refuses any database
|
||||
* whose name does not end in `_bench`.
|
||||
*
|
||||
* @package TMDO\Tests\Bench
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( 'cli' !== PHP_SAPI ) {
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/class-tmdo-bench-seeder.php';
|
||||
|
||||
$opts = getopt( '', array( 'vendors::', 'listings::', 'seed::', 'batch::' ) );
|
||||
$vendors = (int) ( $opts['vendors'] ?? 1000 );
|
||||
$listings = (int) ( $opts['listings'] ?? 10000 );
|
||||
$db_name = (string) getenv( 'TMDO_TEST_DB_NAME' );
|
||||
|
||||
try {
|
||||
// Before the bootstrap: it runs CREATE DATABASE on whatever name it is given.
|
||||
TMDO_Bench_Seeder::assert_bench_db( $db_name );
|
||||
TMDO_Bench_Seeder::assert_count( 'vendors', $vendors );
|
||||
TMDO_Bench_Seeder::assert_count( 'listings', $listings );
|
||||
} catch ( InvalidArgumentException $e ) {
|
||||
fwrite( STDERR, $e->getMessage() . PHP_EOL );
|
||||
exit( 2 );
|
||||
}
|
||||
|
||||
// Schema_Manager / Installer require ABSPATH . 'wp-admin/includes/upgrade.php'.
|
||||
define( 'ABSPATH', __DIR__ . '/wp-stub/' );
|
||||
require dirname( __DIR__ ) . '/integration/bootstrap.php';
|
||||
|
||||
$started = microtime( true );
|
||||
$seeder = new TMDO_Bench_Seeder( $_mysqli, $wpdb->prefix, $vendors, $listings, (int) ( $opts['seed'] ?? 42 ), (int) ( $opts['batch'] ?? 2000 ) );
|
||||
$counts = $seeder->run();
|
||||
|
||||
printf( "Seeded %s: %d vendors, %d listings in %.1fs\n", $db_name, $vendors, $listings, microtime( true ) - $started );
|
||||
foreach ( $counts as $table => $rows ) {
|
||||
printf( " %-40s %12d\n", $table, $rows );
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
/**
|
||||
* Stand-in for WordPress's wp-admin/includes/upgrade.php during bench seeding.
|
||||
*
|
||||
* TMDO_Schema_Manager and TMDO_Installer require this file for dbDelta();
|
||||
* tests/integration/bootstrap.php already provides dbDelta(), so it is empty.
|
||||
*
|
||||
* @package TMDO\Tests\Bench
|
||||
*/
|
||||
+28
-6
@@ -230,8 +230,10 @@ if ( ! function_exists( 'esc_sql' ) ) {
|
||||
return addslashes( $s );
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } }
|
||||
if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } }
|
||||
if ( ! function_exists( '_doing_it_wrong' ) ) {
|
||||
function _doing_it_wrong( string $fn, string $msg, string $ver ): void {}
|
||||
function _doing_it_wrong( string $fn, string $msg, string $ver ): void { $GLOBALS['_doing_it_wrong_calls'][] = array( $fn, $msg ); }
|
||||
}
|
||||
if ( ! function_exists( 'get_option' ) ) {
|
||||
function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; }
|
||||
@@ -426,6 +428,18 @@ if ( ! function_exists( 'wp_cache_delete' ) ) {
|
||||
function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; }
|
||||
}
|
||||
|
||||
// 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 +448,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; }
|
||||
}
|
||||
@@ -492,10 +506,12 @@ if ( ! function_exists( 'register_rest_route' ) ) {
|
||||
// WP_CLI stub (needed before loading CLI classes).
|
||||
if ( ! class_exists( 'WP_CLI' ) ) {
|
||||
class WP_CLI {
|
||||
public static function log( string $msg ): void {}
|
||||
public static function warning( string $msg ): void {}
|
||||
public static function success( string $msg ): void {}
|
||||
public static function error( string $msg ): void {}
|
||||
// Output is kept in $GLOBALS['_wp_cli_output'] so CLI tests can assert it.
|
||||
public static function log( string $msg ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
|
||||
public static function line( string $msg = '' ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
|
||||
public static function warning( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Warning: ' . $msg; }
|
||||
public static function success( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Success: ' . $msg; }
|
||||
public static function error( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Error: ' . $msg; }
|
||||
public static function add_command( string $name, $class ): void {}
|
||||
}
|
||||
}
|
||||
@@ -599,6 +615,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
|
||||
// Engine (v2.0.0).
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
|
||||
@@ -607,6 +624,7 @@ require_once TMDO_PATH . 'includes/engine/class-tmdo-query-compiler.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php';
|
||||
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
|
||||
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
|
||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
|
||||
@@ -630,6 +648,10 @@ 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 . 'includes/migration/class-tmdo-native-restorer.php';
|
||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
|
||||
|
||||
// Options Manager.
|
||||
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Repair of text / textarea flat columns holding the literal "Array" (stored
|
||||
* by the pre-1.1.0 type caster when the meta value was an array).
|
||||
*
|
||||
* Fixture (user entity, group al_prefs):
|
||||
* user 1 prefs 'Array' native prefs serialized array → repairable
|
||||
* user 2 loc 'Array' native loc 'Array' too → unrepairable, native polluted
|
||||
* user 3 name 'Array' no native row → unrepairable
|
||||
* user 4 prefs serialized array, loc 'Taipei' → untouched
|
||||
*/
|
||||
class ArrayLiteralRepairTest extends TestCase {
|
||||
|
||||
private const TABLE = 'wp_itest_wpdo_user_al_prefs';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-type-caster.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
'includes/migration/class-tmdo-array-literal-repair.php',
|
||||
) as $file ) {
|
||||
require_once WPDO_PLUGIN_DIR . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, prefs TEXT DEFAULT NULL, loc VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||
|
||||
$prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) );
|
||||
$wpdb->insert( self::TABLE, array( 'user_id' => 1, 'prefs' => 'Array' ) );
|
||||
$wpdb->insert( self::TABLE, array( 'user_id' => 2, 'loc' => 'Array' ) );
|
||||
$wpdb->insert( self::TABLE, array( 'user_id' => 3, 'name' => 'Array' ) );
|
||||
$wpdb->insert( self::TABLE, array( 'user_id' => 4, 'prefs' => $prefs, 'loc' => 'Taipei' ) );
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'prefs', 'meta_value' => $prefs ) );
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 2, 'meta_key' => 'loc', 'meta_value' => 'Array' ) );
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'prefs', 'meta_value' => $prefs ) );
|
||||
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||
WPDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'al_prefs',
|
||||
array(
|
||||
array( 'key' => 'prefs', 'type' => 'textarea' ),
|
||||
array( 'key' => 'loc', 'type' => 'text' ),
|
||||
array( 'key' => 'name', 'type' => 'text' ),
|
||||
)
|
||||
);
|
||||
WPDO_Schema_Manager::flush_table_exists_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( $wpdb->usermeta, self::TABLE ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
private function flat( int $user_id, string $col ): ?string {
|
||||
global $wpdb;
|
||||
return $wpdb->get_var( $wpdb->prepare( "SELECT `{$col}` FROM `" . self::TABLE . '` WHERE user_id = %d', $user_id ) );
|
||||
}
|
||||
|
||||
public function test_dry_run_reports_without_writing(): void {
|
||||
$report = TMDO_Array_Literal_Repair::run( 'user', false );
|
||||
|
||||
$this->assertSame( 3, $report['found'] );
|
||||
$this->assertSame( 1, $report['repairable'] );
|
||||
$this->assertSame( 0, $report['repaired'] );
|
||||
$this->assertSame( 'Array', $this->flat( 1, 'prefs' ) );
|
||||
}
|
||||
|
||||
public function test_apply_repairs_from_native_and_lists_the_rest(): void {
|
||||
$report = TMDO_Array_Literal_Repair::run( 'user', true );
|
||||
|
||||
$this->assertSame( 1, $report['repaired'] );
|
||||
$this->assertSame( serialize( array( 'core' => array( 'welcomeGuide' => false ) ) ), $this->flat( 1, 'prefs' ) );
|
||||
$this->assertSame( 'Array', $this->flat( 2, 'loc' ), 'native is "Array" too — nothing to repair from' );
|
||||
$this->assertSame( 'Array', $this->flat( 3, 'name' ), 'no native row' );
|
||||
$this->assertSame( 'Taipei', $this->flat( 4, 'loc' ) );
|
||||
|
||||
$unrepairable = array_map(
|
||||
static function ( $r ) {
|
||||
return $r['id'] . ':' . $r['key'] . ':' . $r['reason'];
|
||||
},
|
||||
$report['unrepairable']
|
||||
);
|
||||
sort( $unrepairable );
|
||||
$this->assertSame( array( '2:loc:native_is_array_literal', '3:name:no_native_row' ), $unrepairable );
|
||||
|
||||
$this->assertSame( array( array( 'group' => 'al_prefs', 'key' => 'loc', 'id' => 2 ) ), $report['native_polluted'] );
|
||||
}
|
||||
|
||||
public function test_second_run_finds_only_the_unrepairable_rows(): void {
|
||||
TMDO_Array_Literal_Repair::run( 'user', true );
|
||||
|
||||
$report = TMDO_Array_Literal_Repair::run( 'user', true );
|
||||
|
||||
$this->assertSame( 2, $report['found'] );
|
||||
$this->assertSame( 0, $report['repaired'] );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* `wp tmdo cleanup-hp-transients --options` (2.2 PR-080): the HivePress AddOn
|
||||
* moved HivePress's per-post transients out of wp_postmeta into wp_options
|
||||
* (`_transient_wpdo_hp_pm_*`), where they piled up — 1.15 M rows on dev36. Since
|
||||
* PR-081 they live in the object cache when there is one; this removes the
|
||||
* rows left behind. Works without the AddOn loaded (as in core CI).
|
||||
*/
|
||||
class CleanupHpTransientsTest extends TestCase {
|
||||
|
||||
private const OPTIONS = 'wp_itest_options';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
require_once WPDO_PLUGIN_DIR . 'cli/class-tmdo-cli-post.php';
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::OPTIONS . '`' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::OPTIONS . "` ( option_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, option_name VARCHAR(191) NOT NULL DEFAULT '', option_value LONGTEXT NOT NULL, autoload VARCHAR(20) NOT NULL DEFAULT 'yes', PRIMARY KEY (option_id), UNIQUE KEY option_name (option_name) ) ENGINE=InnoDB" );
|
||||
for ( $i = 1; $i <= 5; $i++ ) {
|
||||
$wpdb->insert( self::OPTIONS, array( 'option_name' => "_transient_wpdo_hp_pm_{$i}_abc", 'option_value' => 'v', 'autoload' => 'no' ) );
|
||||
$wpdb->insert( self::OPTIONS, array( 'option_name' => "_transient_timeout_wpdo_hp_pm_{$i}_abc", 'option_value' => '1', 'autoload' => 'no' ) );
|
||||
}
|
||||
foreach ( array( 'siteurl', '_transient_other_plugin', '_transient_wpdo_hp_pmx', 'wpdo_hp_pm_1_abc' ) as $name ) {
|
||||
$wpdb->insert( self::OPTIONS, array( 'option_name' => $name, 'option_value' => 'keep' ) );
|
||||
}
|
||||
$GLOBALS['_wp_cli_output'] = array();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::OPTIONS . '`' );
|
||||
}
|
||||
|
||||
private function remaining(): array {
|
||||
global $wpdb;
|
||||
return $wpdb->get_col( 'SELECT option_name FROM `' . self::OPTIONS . '` ORDER BY option_name' );
|
||||
}
|
||||
|
||||
public function test_dry_run_counts_the_rows_and_deletes_nothing(): void {
|
||||
( new TMDO_CLI_Post() )->cleanup_hp_transients( array(), array( 'options' => true, 'dry-run' => true ) );
|
||||
|
||||
$this->assertCount( 14, $this->remaining() );
|
||||
$this->assertStringContainsString( '10 HivePress transient row(s) in wp_options', implode( "\n", $GLOBALS['_wp_cli_output'] ) );
|
||||
}
|
||||
|
||||
public function test_confirm_deletes_only_the_hivepress_rows_in_batches(): void {
|
||||
( new TMDO_CLI_Post() )->cleanup_hp_transients( array(), array( 'options' => true, 'confirm' => true, 'batch' => '3' ) );
|
||||
|
||||
$this->assertSame( array( '_transient_other_plugin', '_transient_wpdo_hp_pmx', 'siteurl', 'wpdo_hp_pm_1_abc' ), $this->remaining() );
|
||||
$this->assertStringContainsString( 'Deleted 10 HivePress transient row(s) from wp_options', implode( "\n", $GLOBALS['_wp_cli_output'] ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* `wp tmdo bridge-status` progress (2.2 PR-076): eav_ids of a post group that
|
||||
* belongs to some post types counted every post holding one of its keys, so a
|
||||
* fully backfilled group showed less than 100%.
|
||||
*
|
||||
* flat_rows counted rows without any value too (wp_core holds an all-NULL row
|
||||
* for every post): dev36 showed wp_core at 15306%.
|
||||
*
|
||||
* Fixture: group bs_grp for hp_listing only; bs_price on post 1 (hp_listing)
|
||||
* and post 2 (a plain post); flat rows for post 1 (a value) and post 3
|
||||
* (hp_listing, all NULL).
|
||||
*/
|
||||
class CliBridgeStatusTest extends TestCase {
|
||||
|
||||
private const POSTS = 'wp_itest_posts';
|
||||
private const POSTMETA = 'wp_itest_postmeta';
|
||||
private const FLAT = 'wp_itest_wpdo_post_bs_grp';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/adapters/class-tmdo-adapter-post.php',
|
||||
'cli/class-tmdo-cli-v2.php',
|
||||
) as $file ) {
|
||||
require_once WPDO_PLUGIN_DIR . $file;
|
||||
}
|
||||
if ( ! function_exists( 'WP_CLI\Utils\format_items' ) ) {
|
||||
eval( 'namespace WP_CLI\Utils; function format_items( $format, $items, $fields ) { $GLOBALS["_wp_cli_items"] = $items; }' ); // phpcs:ignore Squiz.PHP.Eval -- test-only WP-CLI stub.
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( 'CREATE TABLE `' . self::POSTS . "` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_type VARCHAR(20) NOT NULL DEFAULT 'post', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::POSTMETA . '` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::FLAT . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, bs_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->insert( self::POSTS, array( 'ID' => 1, 'post_type' => 'hp_listing' ) );
|
||||
$wpdb->insert( self::POSTS, array( 'ID' => 2, 'post_type' => 'post' ) );
|
||||
$wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => 'bs_price', 'meta_value' => '10' ) );
|
||||
$wpdb->insert( self::POSTMETA, array( 'post_id' => 2, 'meta_key' => 'bs_price', 'meta_value' => '20' ) );
|
||||
$wpdb->insert( self::FLAT, array( 'post_id' => 1, 'bs_price' => 10 ) );
|
||||
$wpdb->insert( self::POSTS, array( 'ID' => 3, 'post_type' => 'hp_listing' ) );
|
||||
$wpdb->insert( self::FLAT, array( 'post_id' => 3 ) );
|
||||
|
||||
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'post' => 'dual_write' ) );
|
||||
$GLOBALS['_wp_cli_items'] = array();
|
||||
$GLOBALS['_wp_cli_output'] = array();
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'bs_grp', array( array( 'key' => 'bs_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( self::POSTS, self::POSTMETA, self::FLAT ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_counts_only_flat_rows_that_hold_a_value_and_posts_of_the_groups_types(): void {
|
||||
( new TMDO_CLI_V2() )->bridge_status( array(), array() );
|
||||
|
||||
$row = array_values( array_filter( $GLOBALS['_wp_cli_items'], static fn( $r ) => 'bs_grp' === $r['group'] ) )[0];
|
||||
$this->assertSame( 1, $row['flat_rows'] );
|
||||
$this->assertSame( 1, $row['eav_ids'] );
|
||||
$this->assertSame( '100%', $row['progress_%'] );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* `wp tmdo member-backfill` takes its groups from the registry (2.2 PR-068).
|
||||
*
|
||||
* Before: a hard-coded VALID_GROUPS list (with the never-registered `sso`)
|
||||
* rejected groups registered by add-ons, and there was no way to backfill
|
||||
* every user group at once — R-2.1 had to fall back to `wp eval`.
|
||||
*
|
||||
* Fixture (user entity): groups cb_one (cb_a) and cb_two (cb_b).
|
||||
*/
|
||||
class CliMemberBackfillTest extends TestCase {
|
||||
|
||||
private const ONE = 'wp_itest_wpdo_user_cb_one';
|
||||
private const TWO = 'wp_itest_wpdo_user_cb_two';
|
||||
private const STATUS = 'wp_itest_wpdo_migration_status';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
'cli/class-tmdo-cli-member.php',
|
||||
) as $file ) {
|
||||
require_once WPDO_PLUGIN_DIR . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::ONE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::TWO . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_b VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_a', 'meta_value' => 'one' ) );
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_b', 'meta_value' => 'two' ) );
|
||||
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||
WPDO_Entity_Registry::register_group( 'user', 'cb_one', array( array( 'key' => 'cb_a', 'type' => 'text' ) ) );
|
||||
WPDO_Entity_Registry::register_group( 'user', 'cb_two', array( array( 'key' => 'cb_b', 'type' => 'text' ) ) );
|
||||
WPDO_Schema_Manager::flush_table_exists_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( $wpdb->usermeta, self::ONE, self::TWO, self::STATUS ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
private function flat( string $table, string $col ): ?string {
|
||||
global $wpdb;
|
||||
return $wpdb->get_var( "SELECT `{$col}` FROM `{$table}` WHERE user_id = 1" );
|
||||
}
|
||||
|
||||
public function test_all_backfills_every_registered_user_group(): void {
|
||||
( new TMDO_CLI_Member() )->member_backfill( array(), array( 'all' => true ) );
|
||||
|
||||
$this->assertSame( 'one', $this->flat( self::ONE, 'cb_a' ) );
|
||||
$this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) );
|
||||
}
|
||||
|
||||
public function test_group_accepts_any_registered_user_group(): void {
|
||||
( new TMDO_CLI_Member() )->member_backfill( array(), array( 'group' => 'cb_two' ) );
|
||||
|
||||
$this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) );
|
||||
$this->assertNull( $this->flat( self::ONE, 'cb_a' ), 'only the requested group' );
|
||||
}
|
||||
|
||||
public function test_backfillable_groups_leave_out_restored_orphans(): void {
|
||||
WPDO_Entity_Registry::restore_group( 'user', 'cb_orphan', array( array( 'key' => 'cb_orphan_key', 'type' => 'text' ) ) );
|
||||
|
||||
$this->assertSame( array( 'cb_one', 'cb_two' ), TMDO_Entity_Migration_Engine::backfillable_groups( 'user' ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* `wp tmdo doctor` false alarms (2.2 PR-076).
|
||||
*
|
||||
* The user flat tables were a hard-coded list including wpdo_user_sso, a group
|
||||
* never registered: doctor reported it MISSING and failed on every site. The
|
||||
* tables now come from the registry; doctor also reports Promotion Guard
|
||||
* violations and the recorded drift status.
|
||||
*/
|
||||
class DoctorTest extends TestCase {
|
||||
|
||||
private const HAVE = 'wp_itest_wpdo_user_dt_have';
|
||||
private const CORE = array( 'wp_itest_wpdo_migration_status', 'wp_itest_wpdo_user_points_ledger' );
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
'includes/engine/class-tmdo-promotion-guard.php',
|
||||
'cli/class-tmdo-cli.php',
|
||||
) as $file ) {
|
||||
require_once WPDO_PLUGIN_DIR . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
foreach ( array_merge( array( self::HAVE ), self::CORE ) as $table ) {
|
||||
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINE=InnoDB" );
|
||||
}
|
||||
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
$GLOBALS['_wp_cli_output'] = array();
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||
WPDO_Entity_Registry::register_group( 'user', 'dt_have', array( array( 'key' => 'dt_a', 'type' => 'text' ) ) );
|
||||
WPDO_Schema_Manager::flush_table_exists_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array_merge( array( self::HAVE ), self::CORE ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
private function cli_output(): string {
|
||||
return implode( "\n", $GLOBALS['_wp_cli_output'] ?? array() );
|
||||
}
|
||||
|
||||
public function test_entity_tables_come_from_the_registry(): void {
|
||||
$ok = TMDO_CLI::check_entity_tables();
|
||||
|
||||
$this->assertTrue( $ok, $this->cli_output() );
|
||||
$this->assertStringContainsString( '[OK] ' . self::HAVE, $this->cli_output() );
|
||||
$this->assertStringNotContainsString( 'wpdo_user_sso', $this->cli_output() );
|
||||
}
|
||||
|
||||
public function test_a_registered_group_without_its_table_is_reported(): void {
|
||||
WPDO_Entity_Registry::register_group( 'user', 'dt_missing', array( array( 'key' => 'dt_b', 'type' => 'text' ) ) );
|
||||
|
||||
$ok = TMDO_CLI::check_entity_tables();
|
||||
|
||||
$this->assertFalse( $ok );
|
||||
$this->assertStringContainsString( '[MISSING] wp_itest_wpdo_user_dt_missing', $this->cli_output() );
|
||||
}
|
||||
|
||||
public function test_reports_drifting_groups(): void {
|
||||
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
|
||||
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ] = array(
|
||||
'user' => array(
|
||||
'dt_have' => array(
|
||||
'miss' => 1,
|
||||
'diff' => 2,
|
||||
'extra' => 0,
|
||||
'sample' => 0,
|
||||
'checked_at' => '2026-09-27 00:00:00',
|
||||
),
|
||||
),
|
||||
);
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
|
||||
TMDO_CLI::report_guard_and_drift();
|
||||
|
||||
$this->assertStringContainsString( 'dt_have miss=1 diff=2 extra=0', $this->cli_output() );
|
||||
$this->assertStringContainsString( 'wp tmdo drift-check user --fix', $this->cli_output() );
|
||||
}
|
||||
|
||||
public function test_ignores_the_record_of_a_group_no_longer_registered(): void {
|
||||
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
|
||||
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ] = array(
|
||||
'user' => array(
|
||||
'dt_have' => array(
|
||||
'miss' => 0,
|
||||
'diff' => 0,
|
||||
'extra' => 0,
|
||||
'sample' => 0,
|
||||
'checked_at' => '2026-09-27 00:00:00',
|
||||
),
|
||||
'dt_gone' => array(
|
||||
'miss' => 3,
|
||||
'diff' => 0,
|
||||
'extra' => 0,
|
||||
'sample' => 0,
|
||||
'checked_at' => '2026-09-20 00:00:00',
|
||||
),
|
||||
),
|
||||
);
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
|
||||
TMDO_CLI::report_guard_and_drift();
|
||||
|
||||
$this->assertStringContainsString( '[OK] user: 1 group(s) clean (oldest check 2026-09-27 00:00:00 UTC)', $this->cli_output() );
|
||||
$this->assertStringNotContainsString( 'dt_gone', $this->cli_output() );
|
||||
}
|
||||
|
||||
public function test_reports_an_entity_never_checked(): void {
|
||||
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
|
||||
TMDO_CLI::report_guard_and_drift();
|
||||
|
||||
$this->assertStringContainsString( 'user: never checked', $this->cli_output() );
|
||||
$this->assertStringContainsString( 'Promotion Guard: no violation', $this->cli_output() );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* TMDO_Drift_Check (2.2 PR-070): read-only flat-vs-native comparison.
|
||||
*
|
||||
* Fixture (user entity, group dc_grp):
|
||||
* user 1 every key equal in value, differently formatted
|
||||
* (50 vs 50.000000, serialized array vs JSON, required flag holding
|
||||
* its default with no native row) → clean
|
||||
* user 2 name 'b' vs 'B', cnt 5 vs 6 → diff ×2
|
||||
* user 3 native name, flat row with name NULL → miss
|
||||
* user 4 no native rows, flat cnt 9 → extra
|
||||
* user 5 native name '', flat NULL → clean
|
||||
* user 6 native name longer than a text column, flat NULL → clean
|
||||
* user 7 native cnt, no flat row → miss
|
||||
*/
|
||||
class DriftCheckTest extends TestCase {
|
||||
|
||||
private const TABLE = 'wp_itest_wpdo_user_dc_grp';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
'includes/diagnostic/class-tmdo-drift-check.php',
|
||||
) as $file ) {
|
||||
require_once WPDO_PLUGIN_DIR . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->users}` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_login VARCHAR(60) NOT NULL DEFAULT '', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, num DECIMAL(18,6) DEFAULT NULL, cnt BIGINT(20) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, prefs TEXT DEFAULT NULL, meta_json LONGTEXT DEFAULT NULL, flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||
|
||||
for ( $id = 1; $id <= 7; $id++ ) {
|
||||
$wpdb->insert( $wpdb->users, array( 'ID' => $id, 'user_login' => "u{$id}" ) );
|
||||
}
|
||||
$prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) );
|
||||
$this->native( 1, array( 'num' => '50', 'cnt' => '3', 'name' => 'a', 'prefs' => $prefs, 'meta_json' => serialize( array( 'w' => 640, 'tags' => array( 'x' ) ) ) ) );
|
||||
$this->flat( array( 'user_id' => 1, 'num' => '50.000000', 'cnt' => 3, 'name' => 'a', 'prefs' => $prefs, 'meta_json' => '{"w":640,"tags":["x"]}' ) );
|
||||
$this->native( 2, array( 'name' => 'b', 'cnt' => '5' ) );
|
||||
$this->flat( array( 'user_id' => 2, 'name' => 'B', 'cnt' => 6 ) );
|
||||
$this->native( 3, array( 'name' => 'c' ) );
|
||||
$this->flat( array( 'user_id' => 3, 'cnt' => null ) );
|
||||
$this->flat( array( 'user_id' => 4, 'cnt' => 9 ) );
|
||||
$this->native( 5, array( 'name' => '' ) );
|
||||
$this->flat( array( 'user_id' => 5 ) );
|
||||
$this->native( 6, array( 'name' => str_repeat( 'x', 300 ) ) );
|
||||
$this->flat( array( 'user_id' => 6 ) );
|
||||
$this->native( 7, array( 'cnt' => '7' ) );
|
||||
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||
WPDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'dc_grp',
|
||||
array(
|
||||
array( 'key' => 'num', 'type' => 'decimal' ),
|
||||
array( 'key' => 'cnt', 'type' => 'integer' ),
|
||||
array( 'key' => 'name', 'type' => 'text' ),
|
||||
array( 'key' => 'prefs', 'type' => 'textarea' ),
|
||||
array( 'key' => 'meta_json', 'type' => 'json' ),
|
||||
array( 'key' => 'flag', 'type' => 'integer', 'default' => 0, 'required' => true ),
|
||||
)
|
||||
);
|
||||
WPDO_Schema_Manager::flush_table_exists_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( $wpdb->users, $wpdb->usermeta, self::TABLE ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
private function native( int $user_id, array $values ): void {
|
||||
global $wpdb;
|
||||
foreach ( $values as $key => $value ) {
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) );
|
||||
}
|
||||
}
|
||||
|
||||
private function flat( array $row ): void {
|
||||
global $wpdb;
|
||||
$wpdb->insert( self::TABLE, $row );
|
||||
}
|
||||
|
||||
private function snapshot(): string {
|
||||
global $wpdb;
|
||||
return md5( wp_json_encode( array( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ), $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) );
|
||||
}
|
||||
|
||||
public function test_counts_miss_diff_and_extra_per_key(): void {
|
||||
$report = TMDO_Drift_Check::run( 'user' );
|
||||
|
||||
$this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] );
|
||||
$keys = $report['groups']['dc_grp']['keys'];
|
||||
$this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 0 ), $keys['name'] );
|
||||
$this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 1 ), $keys['cnt'] );
|
||||
foreach ( array( 'num', 'prefs', 'meta_json', 'flag' ) as $key ) {
|
||||
$this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $keys[ $key ], "{$key}: equal by value" );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_records_the_outcome_per_group(): void {
|
||||
TMDO_Drift_Check::run( 'user', 'dc_grp' );
|
||||
|
||||
$status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp'];
|
||||
$this->assertSame( 2, $status['miss'] );
|
||||
$this->assertSame( 2, $status['diff'] );
|
||||
$this->assertSame( 1, $status['extra'] );
|
||||
$this->assertSame( 0, $status['sample'] );
|
||||
$this->assertNotEmpty( $status['checked_at'] );
|
||||
}
|
||||
|
||||
public function test_a_sample_covering_every_user_matches_the_full_check(): void {
|
||||
$report = TMDO_Drift_Check::run( 'user', '', 100 );
|
||||
|
||||
$this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] );
|
||||
$this->assertSame( 100, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['sample'] );
|
||||
}
|
||||
|
||||
public function test_never_writes_native_or_flat(): void {
|
||||
$before = $this->snapshot();
|
||||
|
||||
TMDO_Drift_Check::run( 'user' );
|
||||
|
||||
$this->assertSame( $before, $this->snapshot() );
|
||||
}
|
||||
|
||||
// ── fix() (2.2 PR-071) ──────────────────────────────────────────────────
|
||||
|
||||
private function flat_row( int $user_id ): ?array {
|
||||
global $wpdb;
|
||||
return $wpdb->get_row( $wpdb->prepare( 'SELECT num, cnt, name, flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A );
|
||||
}
|
||||
|
||||
public function test_fix_rebuilds_drifted_rows_from_native_and_leaves_no_drift(): void {
|
||||
$result = TMDO_Drift_Check::fix( 'user' );
|
||||
|
||||
$this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $result['after']['total'] );
|
||||
$this->assertSame( 'b', $this->flat_row( 2 )['name'] );
|
||||
$this->assertSame( '5', $this->flat_row( 2 )['cnt'] );
|
||||
$this->assertSame( 'c', $this->flat_row( 3 )['name'] );
|
||||
$this->assertSame( array( 'num' => null, 'cnt' => '7', 'name' => null, 'flag' => '0' ), $this->flat_row( 7 ), 'new row: no invented defaults' );
|
||||
$this->assertSame( array( 'num' => null, 'cnt' => null, 'name' => null, 'flag' => '0' ), $this->flat_row( 4 ), 'no native values: row kept, columns cleared' );
|
||||
$this->assertSame( 0, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['diff'] );
|
||||
}
|
||||
|
||||
public function test_fix_deletes_rows_of_entities_that_no_longer_exist(): void {
|
||||
$this->flat( array( 'user_id' => 99, 'name' => 'ghost' ) );
|
||||
|
||||
$result = TMDO_Drift_Check::fix( 'user' );
|
||||
|
||||
$this->assertSame( 1, $result['fixed']['deleted_rows'] );
|
||||
$this->assertNull( $this->flat_row( 99 ) );
|
||||
$this->assertNotNull( $this->flat_row( 1 ), 'rows of existing users stay' );
|
||||
}
|
||||
|
||||
public function test_fix_never_writes_native_and_invalidates_the_row_cache(): void {
|
||||
global $wpdb;
|
||||
$native = md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) );
|
||||
TMDO_Cache_Orchestrator::set_row( 'user', 2, 'dc_grp', array( 'name' => 'B' ) );
|
||||
|
||||
TMDO_Drift_Check::fix( 'user' );
|
||||
|
||||
$this->assertSame( $native, md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) ) );
|
||||
$this->assertFalse( TMDO_Cache_Orchestrator::get_row( 'user', 2, 'dc_grp' ) );
|
||||
}
|
||||
|
||||
// ── cron_tick() (2.2 PR-073) ────────────────────────────────────────────
|
||||
|
||||
private static function set_modes( ?array $modes ): void {
|
||||
$cache = ( new ReflectionClass( WPDO_Mode_Manager::class ) )->getProperty( 'cache' );
|
||||
$cache->setAccessible( true );
|
||||
$cache->setValue( null, $modes );
|
||||
}
|
||||
|
||||
public function test_the_daily_tick_samples_entities_that_write_to_flat(): void {
|
||||
self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
try {
|
||||
TMDO_Drift_Check::cron_tick();
|
||||
} finally {
|
||||
self::set_modes( null );
|
||||
}
|
||||
|
||||
$status = get_option( TMDO_Drift_Check::OPTION );
|
||||
$this->assertSame( TMDO_Drift_Check::CRON_SAMPLE, $status['user']['dc_grp']['sample'] );
|
||||
$this->assertSame( 2, $status['user']['dc_grp']['diff'], 'the sample covers all 7 fixture users' );
|
||||
$this->assertArrayNotHasKey( 'post', $status );
|
||||
}
|
||||
|
||||
public function test_the_daily_tick_skips_entities_that_do_not_write_to_flat(): void {
|
||||
self::set_modes( array( 'post' => 'disabled', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
try {
|
||||
TMDO_Drift_Check::cron_tick();
|
||||
} finally {
|
||||
self::set_modes( null );
|
||||
}
|
||||
|
||||
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
|
||||
}
|
||||
|
||||
public function test_the_daily_tick_skips_aeav_only_where_flat_is_the_source_of_truth(): void {
|
||||
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
try {
|
||||
TMDO_Drift_Check::cron_tick();
|
||||
} finally {
|
||||
self::set_modes( null );
|
||||
}
|
||||
|
||||
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
|
||||
}
|
||||
|
||||
public function test_fix_refuses_aeav_only_and_leaves_flat_alone(): void {
|
||||
global $wpdb;
|
||||
$flat = md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) );
|
||||
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
try {
|
||||
TMDO_Drift_Check::fix( 'user' );
|
||||
$this->fail( 'fix() must refuse aeav_only' );
|
||||
} catch ( RuntimeException $e ) {
|
||||
$this->assertStringContainsString( 'aeav_only', $e->getMessage() );
|
||||
} finally {
|
||||
self::set_modes( null );
|
||||
}
|
||||
|
||||
$this->assertSame( $flat, md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) );
|
||||
}
|
||||
|
||||
// ── resync after writes bypassed flat (2.2 PR-074) ──────────────────────
|
||||
|
||||
public function test_turning_writes_on_schedules_a_fix_for_that_entity(): void {
|
||||
$this->assertSame( array( 'user' ), TMDO_Drift_Check::resync_after_mode_change( 'user', 'dual_write', 'disabled' ) );
|
||||
}
|
||||
|
||||
public function test_other_mode_changes_schedule_nothing(): void {
|
||||
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'shadow_read', 'dual_write' ) );
|
||||
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'aeav_only', 'shadow_read' ) );
|
||||
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'disabled', 'dual_write' ) );
|
||||
}
|
||||
|
||||
public function test_turning_the_hook_bus_on_schedules_a_fix_for_entities_that_keep_native(): void {
|
||||
self::set_modes( array( 'post' => 'aeav_only', 'user' => 'dual_write', 'term' => 'shadow_read', 'comment' => 'disabled' ) );
|
||||
try {
|
||||
$on = TMDO_Drift_Check::resync_after_hook_bus_change( '0', '1' );
|
||||
$off = TMDO_Drift_Check::resync_after_hook_bus_change( '1', '0' );
|
||||
} finally {
|
||||
self::set_modes( null );
|
||||
}
|
||||
|
||||
$this->assertSame( array( 'user', 'term' ), $on );
|
||||
$this->assertSame( array(), $off );
|
||||
}
|
||||
|
||||
public function test_the_scheduled_fix_repairs_the_entity(): void {
|
||||
self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
try {
|
||||
TMDO_Drift_Check::run_scheduled_fix( 'user' );
|
||||
} finally {
|
||||
self::set_modes( null );
|
||||
}
|
||||
|
||||
$status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp'];
|
||||
$this->assertSame( 0, $status['miss'] + $status['diff'] + $status['extra'] );
|
||||
}
|
||||
|
||||
public function test_the_scheduled_fix_leaves_aeav_only_alone(): void {
|
||||
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
try {
|
||||
TMDO_Drift_Check::run_scheduled_fix( 'user' );
|
||||
} finally {
|
||||
self::set_modes( null );
|
||||
}
|
||||
|
||||
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
|
||||
$this->assertSame( 'B', $this->flat_row( 2 )['name'], 'flat untouched' );
|
||||
}
|
||||
|
||||
public function test_the_resync_triggers_are_hooked(): void {
|
||||
$source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' );
|
||||
|
||||
foreach ( array(
|
||||
"add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );",
|
||||
"add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );",
|
||||
"add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );",
|
||||
"add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );",
|
||||
) as $line ) {
|
||||
$this->assertStringContainsString( $line, $source );
|
||||
}
|
||||
// Literal: uninstall.php does not load TMDO_Drift_Check.
|
||||
$installer = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php' );
|
||||
$this->assertSame( 2, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'cleared on deactivate and uninstall' );
|
||||
}
|
||||
|
||||
public function test_the_daily_tick_runs_on_the_daily_health_check_hook(): void {
|
||||
$source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' );
|
||||
|
||||
$this->assertStringContainsString( "add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );", $source );
|
||||
}
|
||||
|
||||
public function test_rejects_an_unknown_group(): void {
|
||||
$this->expectException( InvalidArgumentException::class );
|
||||
TMDO_Drift_Check::run( 'user', 'no_such_group' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* TMDO_Entity_Migration_Engine::migrate_group() must not invent values for keys
|
||||
* the object has no native row for.
|
||||
*
|
||||
* Bug (dev36 R-2.1, 2026-09-26): the engine REPLACEs only the columns it found
|
||||
* native rows for, so every other column fell back to its DDL default —
|
||||
* user activity.session_count / account_flags (nullable, DEFAULT 0) became 0
|
||||
* for every backfilled user, and the checker's "extra" count went 60 → 123.
|
||||
*
|
||||
* Fixture (user entity, group md_act):
|
||||
* login_count integer, default 0, nullable
|
||||
* session_count integer, default 0, nullable
|
||||
* note text
|
||||
* req_flag integer, required (NOT NULL DEFAULT 0)
|
||||
*/
|
||||
class EntityMigrationEngineDefaultsTest extends TestCase {
|
||||
|
||||
private const TABLE = 'wp_itest_wpdo_user_md_act';
|
||||
private const STATUS = 'wp_itest_wpdo_migration_status';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-type-caster.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-entity-migration-engine.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
) as $file ) {
|
||||
require_once WPDO_PLUGIN_DIR . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, login_count BIGINT(20) DEFAULT 0, session_count BIGINT(20) DEFAULT 0, note VARCHAR(255) DEFAULT NULL, req_flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
|
||||
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||
WPDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'md_act',
|
||||
array(
|
||||
array( 'key' => 'login_count', 'type' => 'integer', 'default' => 0 ),
|
||||
array( 'key' => 'session_count', 'type' => 'integer', 'default' => 0 ),
|
||||
array( 'key' => 'note', 'type' => 'text' ),
|
||||
array( 'key' => 'req_flag', 'type' => 'integer', 'default' => 0, 'required' => true ),
|
||||
)
|
||||
);
|
||||
WPDO_Schema_Manager::flush_table_exists_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( $wpdb->usermeta, self::TABLE, self::STATUS ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
private function native( int $user_id, string $key, string $value ): void {
|
||||
global $wpdb;
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) );
|
||||
}
|
||||
|
||||
/** @return array<string, string|null> */
|
||||
private function flat_row( int $user_id ): array {
|
||||
global $wpdb;
|
||||
return (array) $wpdb->get_row( $wpdb->prepare( 'SELECT login_count, session_count, note, req_flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A );
|
||||
}
|
||||
|
||||
private function migrate(): array {
|
||||
return TMDO_Entity_Migration_Engine::migrate_group( 'user', 'md_act', array( 'resume' => false, 'sleep_ms' => 0 ) );
|
||||
}
|
||||
|
||||
public function test_keys_without_a_native_row_stay_null_instead_of_the_column_default(): void {
|
||||
$this->native( 1, 'login_count', '7' );
|
||||
|
||||
$report = $this->migrate();
|
||||
|
||||
$this->assertSame( 1, $report['migrated'] );
|
||||
$row = $this->flat_row( 1 );
|
||||
$this->assertSame( '7', $row['login_count'] );
|
||||
$this->assertNull( $row['session_count'], 'no native session_count — must not become the DEFAULT 0' );
|
||||
$this->assertNull( $row['note'] );
|
||||
}
|
||||
|
||||
public function test_a_native_value_equal_to_the_default_is_still_written(): void {
|
||||
$this->native( 1, 'session_count', '0' );
|
||||
|
||||
$this->migrate();
|
||||
|
||||
$this->assertSame( '0', $this->flat_row( 1 )['session_count'] );
|
||||
}
|
||||
|
||||
public function test_a_stale_flat_value_is_cleared_when_the_native_row_is_gone(): void {
|
||||
global $wpdb;
|
||||
$wpdb->insert( self::TABLE, array( 'user_id' => 1, 'login_count' => 3, 'session_count' => 5, 'note' => 'old' ) );
|
||||
$this->native( 1, 'login_count', '4' );
|
||||
|
||||
$this->migrate();
|
||||
|
||||
$row = $this->flat_row( 1 );
|
||||
$this->assertSame( '4', $row['login_count'] );
|
||||
$this->assertNull( $row['session_count'] );
|
||||
$this->assertNull( $row['note'] );
|
||||
}
|
||||
|
||||
public function test_a_required_column_without_a_native_row_keeps_its_default(): void {
|
||||
$this->native( 1, 'login_count', '1' );
|
||||
|
||||
$report = $this->migrate();
|
||||
|
||||
$this->assertSame( 0, $report['errors'] );
|
||||
$this->assertSame( '0', $this->flat_row( 1 )['req_flag'], 'NOT NULL column cannot hold NULL — its default is the documented stand-in' );
|
||||
}
|
||||
|
||||
public function test_the_cron_batch_path_does_not_invent_defaults_either(): void {
|
||||
$this->native( 1, 'login_count', '2' );
|
||||
|
||||
$result = TMDO_Entity_Migration_Engine::migrate_group_batch( 'user', 'md_act' );
|
||||
|
||||
$this->assertSame( 1, $result['migrated'] );
|
||||
$this->assertNull( $this->flat_row( 1 )['session_count'] );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Composite indexes declared on a group reach its existing flat table.
|
||||
*
|
||||
* The table already exists with data (its schema hash is unchanged), so the
|
||||
* index is added in place rather than through a table rebuild.
|
||||
*/
|
||||
class GroupCompositeIndexTest extends TestCase {
|
||||
|
||||
private const TABLE = 'wp_itest_wpdo_post_ci_core';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/adapters/class-tmdo-adapter-post.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, ci_flag BIGINT(20) DEFAULT NULL, ci_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' );
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
// Index DDL runs from WP-CLI / WP-Cron only (2.3 PR-090a); these tests act as one.
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
|
||||
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true );
|
||||
$GLOBALS['_wp_scheduled_single'] = array();
|
||||
|
||||
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, array() );
|
||||
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'ci_core',
|
||||
array(
|
||||
array( 'key' => 'ci_flag', 'type' => 'integer' ),
|
||||
array( 'key' => 'ci_price', 'type' => 'decimal' ),
|
||||
),
|
||||
array( 'indexes' => array( 'flag_price' => array( 'ci_flag', 'ci_price' ) ) )
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||
WPDO_Entity_Registry::init();
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
|
||||
unset( $GLOBALS['_wp_is_admin'], $GLOBALS['_wp_scheduled_single'] );
|
||||
}
|
||||
|
||||
public function test_a_web_request_adds_no_index_and_leaves_it_to_wp_cron(): void {
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
|
||||
foreach ( array( false, true ) as $is_admin ) { // /wp-admin/ runs init before auth_redirect().
|
||||
$GLOBALS['_wp_is_admin'] = $is_admin;
|
||||
$this->assertFalse( TMDO_Schema_Manager::ddl_allowed() );
|
||||
|
||||
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||
|
||||
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'no ALTER in a web request' );
|
||||
}
|
||||
$this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() );
|
||||
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
|
||||
|
||||
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // The cron run.
|
||||
TMDO_Schema_Manager::ensure_pending_indexes();
|
||||
|
||||
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
|
||||
$this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() );
|
||||
}
|
||||
|
||||
public function test_the_ddl_decision_can_be_overridden(): void {
|
||||
add_filter( 'tmdo_schema_ddl_allowed', static fn() => false );
|
||||
|
||||
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||
|
||||
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes() );
|
||||
}
|
||||
|
||||
public function test_a_failed_alter_is_not_recorded_as_applied(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` ADD COLUMN ci_note TEXT DEFAULT NULL' );
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'ci_core',
|
||||
array(
|
||||
array( 'key' => 'ci_flag', 'type' => 'integer' ),
|
||||
array( 'key' => 'ci_note', 'type' => 'textarea' ),
|
||||
),
|
||||
array( 'indexes' => array( 'flag_note' => array( 'ci_flag', 'ci_note' ) ) ) // TEXT without a prefix length: MySQL 1170.
|
||||
);
|
||||
|
||||
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||
|
||||
$this->assertArrayNotHasKey( 'cidx_flag_note', $this->indexes() );
|
||||
$this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ), 'not recorded, so it is retried' );
|
||||
$this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() );
|
||||
}
|
||||
|
||||
public function test_a_lock_held_by_another_process_skips_the_ddl_until_it_is_released(): void {
|
||||
$other = new mysqli(
|
||||
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
|
||||
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
|
||||
(string) getenv( 'TMDO_TEST_DB_PASS' ),
|
||||
getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test'
|
||||
);
|
||||
$name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() );
|
||||
$this->assertSame( '1', (string) $other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" )->fetch_row()[0] );
|
||||
|
||||
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'another process holds the lock' );
|
||||
|
||||
$other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" );
|
||||
$other->close();
|
||||
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||
|
||||
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
|
||||
$this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() );
|
||||
global $wpdb;
|
||||
$this->assertSame( '1', (string) $wpdb->get_var( $wpdb->prepare( 'SELECT IS_FREE_LOCK(MD5(CONCAT(DATABASE(), %s)))', TMDO_Schema_Manager::index_lock_name() ) ), 'lock released' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Under read/write splitting (HyperDB, LudicrousDB) a SELECT may go to a
|
||||
* replica, where GET_LOCK would lock nothing: reads go to the primary first.
|
||||
*/
|
||||
public function test_the_lock_is_taken_on_the_primary_under_read_write_splitting(): void {
|
||||
global $wpdb;
|
||||
$real = $wpdb;
|
||||
$wpdb = new class( $real ) {
|
||||
/** @var string[] */
|
||||
public array $calls = array();
|
||||
|
||||
public function __construct( private object $db ) {}
|
||||
|
||||
public function send_reads_to_masters(): void {
|
||||
$this->calls[] = 'send_reads_to_masters';
|
||||
}
|
||||
|
||||
public function __call( string $name, array $args ) {
|
||||
if ( 'get_var' === $name && str_contains( (string) $args[0], 'GET_LOCK' ) ) {
|
||||
$this->calls[] = 'GET_LOCK';
|
||||
}
|
||||
return $this->db->$name( ...$args );
|
||||
}
|
||||
|
||||
public function __get( string $name ) {
|
||||
return $this->db->$name;
|
||||
}
|
||||
};
|
||||
try {
|
||||
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||
$calls = $wpdb->calls;
|
||||
} finally {
|
||||
$wpdb = $real;
|
||||
}
|
||||
|
||||
$this->assertSame( array( 'send_reads_to_masters', 'GET_LOCK' ), $calls );
|
||||
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
|
||||
}
|
||||
|
||||
/** @return array<string, string[]> index name → ordered columns */
|
||||
private function indexes(): array {
|
||||
global $wpdb;
|
||||
$out = array();
|
||||
foreach ( (array) $wpdb->get_results( 'SHOW INDEX FROM `' . self::TABLE . '`', ARRAY_A ) as $row ) {
|
||||
$out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function test_missing_composite_index_is_added_to_the_existing_table(): void {
|
||||
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||
|
||||
$this->assertSame( array( 'ci_flag', 'ci_price' ), $this->indexes()['cidx_flag_price'] ?? null );
|
||||
global $wpdb;
|
||||
$this->assertSame( '2', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' ), 'data untouched' );
|
||||
}
|
||||
|
||||
public function test_applied_definition_is_remembered_so_later_requests_skip_the_check(): void {
|
||||
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||
global $wpdb;
|
||||
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` DROP INDEX `cidx_flag_price`' );
|
||||
|
||||
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||
|
||||
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'same definition → no SHOW INDEX / ALTER' );
|
||||
$this->assertArrayHasKey( 'post/ci_core', get_option( TMDO_Schema_Manager::INDEXES_OPTION ) );
|
||||
}
|
||||
|
||||
public function test_group_without_indexes_is_left_alone(): void {
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'ci_core', array( array( 'key' => 'ci_flag', 'type' => 'integer' ) ) );
|
||||
|
||||
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||
|
||||
$this->assertSame( array( 'PRIMARY', 'uk_entity' ), array_keys( $this->indexes() ) );
|
||||
$this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ) );
|
||||
}
|
||||
}
|
||||
@@ -202,14 +202,113 @@ class HookBusIntegrationTest extends TestCase {
|
||||
$this->assertTrue( $result, 'aeav_only must return true to short-circuit EAV write' );
|
||||
}
|
||||
|
||||
public function test_update_writes_flat_and_returns_null_in_dual_write_mode(): void {
|
||||
/**
|
||||
* dual_write: the pre-filter only lets WP write native; flat is synced
|
||||
* from native afterwards (updated_/added_ actions → sync_from_native).
|
||||
*/
|
||||
public function test_update_leaves_flat_to_the_native_sync_in_dual_write_mode(): void {
|
||||
global $wpdb;
|
||||
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
|
||||
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
|
||||
|
||||
$result = WPDO_Hook_Bus::intercept_update( null, 3, 'first_name', 'Carol', '' );
|
||||
|
||||
$this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) );
|
||||
$this->assertNull( $result, 'dual_write must return null so WP continues to write EAV' );
|
||||
$this->assertNull( $this->read_flat_first_name( 3 ), 'no flat write before native is written' );
|
||||
|
||||
$this->with_usermeta(
|
||||
function () use ( $wpdb ) {
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 3, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ) );
|
||||
|
||||
WPDO_Hook_Bus::sync_from_native( 'user', 3, 'first_name', 'Carol', 'update' );
|
||||
|
||||
$this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A group whose table upgrade waits for WP-CLI / WP-Cron (2.3 PR-107)
|
||||
* stays native: its table may lack the new columns.
|
||||
*/
|
||||
public function test_a_group_waiting_for_its_table_upgrade_stays_native(): void {
|
||||
global $wpdb;
|
||||
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
|
||||
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/core_profile' => time() ) );
|
||||
try {
|
||||
$this->with_usermeta(
|
||||
function () use ( $wpdb ) {
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'first_name', 'meta_value' => 'Dora' ) );
|
||||
|
||||
WPDO_Hook_Bus::sync_from_native( 'user', 4, 'first_name', 'Dora', 'update' );
|
||||
|
||||
$this->assertNull( $this->read_flat_first_name( 4 ) );
|
||||
$this->assertSame( 'Dora', WPDO_Hook_Bus::direct_read( 'user', 4, 'first_name' ), 'direct reads return the native value' );
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
delete_option( TMDO_Schema_Manager::DEFERRED_OPTION );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* delete_all passes object ID 0: the delete_ action records the touched
|
||||
* users from the meta IDs, the deleted_ action re-syncs each to NULL.
|
||||
*/
|
||||
public function test_delete_all_resyncs_every_touched_entity_in_dual_write_mode(): void {
|
||||
global $wpdb;
|
||||
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
|
||||
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 21, 'first_name' => 'A' ) );
|
||||
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 22, 'first_name' => 'B' ) );
|
||||
|
||||
$this->with_usermeta(
|
||||
function () use ( $wpdb ) {
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 21, 'meta_key' => 'first_name', 'meta_value' => 'A' ) );
|
||||
$mid_a = (int) $wpdb->insert_id;
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 22, 'meta_key' => 'first_name', 'meta_value' => 'B' ) );
|
||||
$mid_b = (int) $wpdb->insert_id;
|
||||
|
||||
$GLOBALS['_wp_current_filter'] = 'delete_user_meta';
|
||||
WPDO_Hook_Bus::before_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' );
|
||||
$wpdb->query( "DELETE FROM `{$wpdb->usermeta}` WHERE meta_key = 'first_name'" );
|
||||
$GLOBALS['_wp_current_filter'] = 'deleted_user_meta';
|
||||
WPDO_Hook_Bus::on_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' );
|
||||
|
||||
$this->assertNull( $this->read_flat_first_name( 21 ) );
|
||||
$this->assertNull( $this->read_flat_first_name( 22 ) );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting an entity drops its flat row first (adapter delete hook), then
|
||||
* WordPress deletes each meta row; syncing those deletions must not bring
|
||||
* the row back as all NULL.
|
||||
*/
|
||||
public function test_sync_of_a_deleted_key_does_not_recreate_a_missing_row(): void {
|
||||
global $wpdb;
|
||||
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
|
||||
|
||||
$this->with_usermeta(
|
||||
function () use ( $wpdb ) {
|
||||
WPDO_Hook_Bus::sync_from_native( 'user', 31, 'first_name', 'Gone', 'delete' );
|
||||
|
||||
$this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '` WHERE user_id = 31' ) );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run $fn with a native usermeta table, dropped afterwards.
|
||||
*/
|
||||
private function with_usermeta( callable $fn ): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id) ) ENGINE=InnoDB" );
|
||||
try {
|
||||
$fn();
|
||||
} finally {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_update_passthrough_for_unregistered_key(): void {
|
||||
@@ -268,6 +367,30 @@ class HookBusIntegrationTest extends TestCase {
|
||||
$this->assertNull( $result, 'dual_write does not read from flat — must return $check' );
|
||||
}
|
||||
|
||||
/**
|
||||
* shadow_read serves native meta: update_metadata() reads the old value
|
||||
* through this filter, and serving the just-written flat value made WP
|
||||
* skip the native write (dev36, 2026-09-26). Flat is only compared.
|
||||
*/
|
||||
public function test_get_serves_native_in_shadow_read_mode_even_with_a_flat_value(): void {
|
||||
global $wpdb;
|
||||
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 12, 'first_name' => 'Flat' ) );
|
||||
self::set_user_mode( WPDO_Mode_Manager::MODE_SHADOW_READ );
|
||||
|
||||
foreach ( array( 0.0, 1.0 ) as $rate ) {
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] = array(
|
||||
static function () use ( $rate ) {
|
||||
return $rate;
|
||||
},
|
||||
);
|
||||
|
||||
$result = WPDO_Hook_Bus::intercept_get( null, 12, 'first_name', true, 'user' );
|
||||
|
||||
$this->assertNull( $result, "shadow_read must return \$check (sample rate {$rate})" );
|
||||
}
|
||||
unset( $GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] );
|
||||
}
|
||||
|
||||
public function test_get_returns_empty_string_when_column_is_null(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Post write-back follows the same routing reads use.
|
||||
*
|
||||
* Listing 101 has `shared_key` in both group tables: 'L' in its own
|
||||
* (hp_listing-scoped) group, a stray 'V' in the vendor group — the legacy
|
||||
* owner, registered last. Whichever table reads use must be the one copied.
|
||||
*/
|
||||
class NativeRestorerPostTest extends TestCase {
|
||||
|
||||
private const LISTING = 'wp_itest_wpdo_post_nr_listing';
|
||||
private const VENDOR = 'wp_itest_wpdo_post_nr_vendor';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-type-caster.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-mode-manager.php',
|
||||
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||
'includes/adapters/class-tmdo-adapter-post.php',
|
||||
'includes/migration/class-tmdo-native-restorer.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id), KEY post_id (post_id), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" );
|
||||
foreach ( array( self::LISTING, self::VENDOR ) as $table ) {
|
||||
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT '2020-01-01 00:00:00', PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB" );
|
||||
}
|
||||
$wpdb->insert( $wpdb->posts, array( 'ID' => 101, 'post_type' => 'hp_listing' ) );
|
||||
$wpdb->insert( self::LISTING, array( 'post_id' => 101, 'shared_key' => 'L' ) );
|
||||
$wpdb->insert( self::VENDOR, array( 'post_id' => 101, 'shared_key' => 'V' ) );
|
||||
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
$GLOBALS['_wp_transients'] = array();
|
||||
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'nr_listing', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'nr_vendor', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) );
|
||||
|
||||
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, array() );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( $wpdb->posts, $wpdb->postmeta, self::LISTING, self::VENDOR ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
private function native_value(): array {
|
||||
global $wpdb;
|
||||
return $wpdb->get_col( "SELECT meta_value FROM `{$wpdb->postmeta}` WHERE post_id = 101 AND meta_key = 'shared_key'" );
|
||||
}
|
||||
|
||||
public function test_scoped_routing_writes_back_the_post_types_own_group(): void {
|
||||
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '1' );
|
||||
|
||||
TMDO_Native_Restorer::run( 'post' );
|
||||
|
||||
$this->assertSame( array( 'L' ), $this->native_value() );
|
||||
}
|
||||
|
||||
public function test_legacy_routing_writes_back_the_legacy_owner(): void {
|
||||
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '0' );
|
||||
|
||||
TMDO_Native_Restorer::run( 'post' );
|
||||
|
||||
$this->assertSame( array( 'V' ), $this->native_value() );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Retiring one entity group: write its flat values back to native meta, then
|
||||
* forget it so orphan restore never routes its keys to the flat table again.
|
||||
*
|
||||
* Fixture (user, aeav_only): group rt_a (token, ips) and rt_b (other).
|
||||
* rt_a user 1: token 't1', ips '["1.1.1.1"]' user 2: token 't2', ips NULL
|
||||
* rt_b user 1: other 'o1'
|
||||
*/
|
||||
class NativeRestorerRetireTest extends TestCase {
|
||||
|
||||
private const A = 'wp_itest_wpdo_user_rt_a';
|
||||
private const B = 'wp_itest_wpdo_user_rt_b';
|
||||
private const META = 'wp_itest_wpdo_registry_meta';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-type-caster.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-mode-manager.php',
|
||||
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
'includes/migration/class-tmdo-native-restorer.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::A . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, token VARCHAR(255) DEFAULT NULL, ips LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::B . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, other VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( 'INSERT INTO `' . self::A . "` (user_id, token, ips) VALUES (1, 't1', '[\"1.1.1.1\"]'), (2, 't2', NULL)" );
|
||||
$wpdb->insert( self::B, array( 'user_id' => 1, 'other' => 'o1' ) );
|
||||
$wpdb->query( 'UPDATE `' . self::A . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||
$wpdb->query( 'UPDATE `' . self::B . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||
|
||||
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'aeav_only' ) );
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
$GLOBALS['_wp_transients'] = array();
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
|
||||
self::register( true );
|
||||
$wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) );
|
||||
$wpdb->insert(
|
||||
self::META,
|
||||
array(
|
||||
'entity_type' => 'user',
|
||||
'group_name' => 'rt_a',
|
||||
'schema_hash' => 'itest',
|
||||
'field_definitions' => wp_json_encode( WPDO_Entity_Registry::get_group_fields( 'user', 'rt_a' ) ),
|
||||
)
|
||||
);
|
||||
|
||||
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, array() );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
|
||||
/** Fresh registry; rt_b always registered, rt_a only while its provider is active. */
|
||||
private static function register( bool $with_a ): void {
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||
if ( $with_a ) {
|
||||
WPDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'rt_a',
|
||||
array(
|
||||
array( 'key' => 'token', 'type' => 'text' ),
|
||||
array( 'key' => 'ips', 'type' => 'textarea' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
WPDO_Entity_Registry::register_group( 'user', 'rt_b', array( array( 'key' => 'other', 'type' => 'text' ) ) );
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( (array) $wpdb->get_col( "SHOW TABLES LIKE 'wp\\_itest\\_wpdo\\_user\\_rt\\_%'" ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" );
|
||||
$wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) );
|
||||
}
|
||||
|
||||
private function native( int $user_id, string $key ): array {
|
||||
global $wpdb;
|
||||
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$wpdb->usermeta}` WHERE user_id = %d AND meta_key = %s", $user_id, $key ) );
|
||||
}
|
||||
|
||||
public function test_group_option_writes_back_only_that_group(): void {
|
||||
$report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||
|
||||
$this->assertSame( array( 'rt_a' ), array_keys( $report['groups'] ) );
|
||||
$this->assertSame( array( 't1' ), $this->native( 1, 'token' ) );
|
||||
$this->assertSame( array( '["1.1.1.1"]' ), $this->native( 1, 'ips' ) );
|
||||
$this->assertSame( array(), $this->native( 1, 'other' ), 'rt_b untouched' );
|
||||
}
|
||||
|
||||
public function test_retire_refuses_a_group_that_is_still_registered(): void {
|
||||
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||
|
||||
$this->assertSame( 'registered', TMDO_Native_Restorer::retire( 'user', 'rt_a' )['status'] );
|
||||
global $wpdb;
|
||||
$this->assertSame( self::A, $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) );
|
||||
}
|
||||
|
||||
public function test_retire_refuses_when_native_meta_differs_from_the_flat_table(): void {
|
||||
self::register( false );
|
||||
|
||||
$result = TMDO_Native_Restorer::retire( 'user', 'rt_a' );
|
||||
|
||||
$this->assertSame( 'mismatch', $result['status'] );
|
||||
$this->assertSame( 3, $result['mismatches'], 't1, ["1.1.1.1"], t2 are not native yet' );
|
||||
}
|
||||
|
||||
public function test_empty_string_values_count_as_written_back(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'UPDATE `' . self::A . "` SET ips = '' WHERE user_id = 2" );
|
||||
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||
self::register( false );
|
||||
|
||||
$this->assertSame( 'retired', TMDO_Native_Restorer::retire( 'user', 'rt_a' )['status'], "wpdb::get_col() reads '' as null" );
|
||||
}
|
||||
|
||||
public function test_retire_forgets_the_group_and_keeps_its_table_as_a_backup(): void {
|
||||
global $wpdb;
|
||||
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||
self::register( false );
|
||||
|
||||
$result = TMDO_Native_Restorer::retire( 'user', 'rt_a' );
|
||||
|
||||
$this->assertSame( 'retired', $result['status'] );
|
||||
$this->assertMatchesRegularExpression( '/^' . self::A . '_retired_\d{8}$/', $result['archived_table'] );
|
||||
$this->assertSame( $result['archived_table'], $wpdb->get_var( "SHOW TABLES LIKE '" . $result['archived_table'] . "'" ) );
|
||||
$this->assertNull( $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) );
|
||||
$this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::META . "` WHERE entity_type = 'user' AND group_name = 'rt_a'" ) );
|
||||
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups(), 'orphan restore no longer routes the keys to the flat table' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Writing flat-table values back to native meta so an aeav_only entity can
|
||||
* leave aeav_only.
|
||||
*
|
||||
* Rule: after the write-back, native meta returns what reads return today.
|
||||
* Fixture (user entity, both groups hold `nickname`; the legacy owner is
|
||||
* rn_extra, registered last):
|
||||
* rn_profile user 1: nickname 'old', level 3, tags ["a","b"]
|
||||
* user 2: nickname NULL, level 5, tags NULL
|
||||
* rn_extra user 1: nickname 'live'
|
||||
* usermeta user 1 level '1' (stale), user 2 tags 'stale'
|
||||
*/
|
||||
class NativeRestorerTest extends TestCase {
|
||||
|
||||
private const PROFILE = 'wp_itest_wpdo_user_rn_profile';
|
||||
private const EXTRA = 'wp_itest_wpdo_user_rn_extra';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-type-caster.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-mode-manager.php',
|
||||
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
'includes/migration/class-tmdo-native-restorer.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::PROFILE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, nickname VARCHAR(255) DEFAULT NULL, level BIGINT(20) DEFAULT NULL, tags LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::EXTRA . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, nickname VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( 'INSERT INTO `' . self::PROFILE . "` (user_id, nickname, level, tags) VALUES (1, 'old', 3, '[\"a\",\"b\"]'), (2, NULL, 5, NULL)" );
|
||||
$wpdb->insert( self::EXTRA, array( 'user_id' => 1, 'nickname' => 'live' ) );
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'level', 'meta_value' => '1' ) );
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 2, 'meta_key' => 'tags', 'meta_value' => 'stale' ) );
|
||||
// Historical rows: older than any run, so the catch-up pass leaves them alone.
|
||||
$wpdb->query( 'UPDATE `' . self::PROFILE . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||
$wpdb->query( 'UPDATE `' . self::EXTRA . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||
|
||||
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'aeav_only' ) );
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
$GLOBALS['_wp_transients'] = array();
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||
WPDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'rn_profile',
|
||||
array(
|
||||
array( 'key' => 'nickname', 'type' => 'text' ),
|
||||
array( 'key' => 'level', 'type' => 'integer' ),
|
||||
array( 'key' => 'tags', 'type' => 'json' ),
|
||||
)
|
||||
);
|
||||
WPDO_Entity_Registry::register_group( 'user', 'rn_extra', array( array( 'key' => 'nickname', 'type' => 'text' ) ) );
|
||||
|
||||
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, array() );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( $wpdb->usermeta, self::PROFILE, self::EXTRA ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string[] meta_value rows for (user, key). */
|
||||
private function native( int $user_id, string $key ): array {
|
||||
global $wpdb;
|
||||
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$wpdb->usermeta}` WHERE user_id = %d AND meta_key = %s", $user_id, $key ) );
|
||||
}
|
||||
|
||||
public function test_dry_run_counts_without_writing(): void {
|
||||
$report = TMDO_Native_Restorer::run( 'user', true );
|
||||
|
||||
$this->assertSame( 2, $report['groups']['rn_profile']['rows'] );
|
||||
$this->assertSame( 1, $report['groups']['rn_extra']['rows'] );
|
||||
$this->assertSame( array( '1' ), $this->native( 1, 'level' ) );
|
||||
$this->assertFalse( $report['demoted'] );
|
||||
}
|
||||
|
||||
public function test_native_meta_ends_up_matching_current_reads(): void {
|
||||
TMDO_Native_Restorer::run( 'user', false, 1 );
|
||||
|
||||
$this->assertSame( array( 'live' ), $this->native( 1, 'nickname' ), 'the group reads use (rn_extra) wins' );
|
||||
$this->assertSame( array( '3' ), $this->native( 1, 'level' ), 'stale native value replaced' );
|
||||
$this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ), 'json stored the way WP stores arrays' );
|
||||
$this->assertSame( array( '5' ), $this->native( 2, 'level' ) );
|
||||
$this->assertSame( array(), $this->native( 2, 'tags' ), 'NULL flat value removes the stale native row' );
|
||||
$this->assertSame( array(), $this->native( 2, 'nickname' ) );
|
||||
}
|
||||
|
||||
public function test_completion_clears_progress_and_demote_leaves_aeav_only(): void {
|
||||
$report = TMDO_Native_Restorer::run( 'user', false, 500, true );
|
||||
|
||||
$this->assertTrue( $report['demoted'] );
|
||||
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) );
|
||||
$this->assertFalse( get_option( TMDO_Native_Restorer::CURSOR_OPTION ) );
|
||||
$this->assertFalse( get_option( TMDO_Native_Restorer::STARTED_OPTION ) );
|
||||
}
|
||||
|
||||
public function test_resumes_after_the_stored_cursor(): void {
|
||||
update_option( TMDO_Native_Restorer::CURSOR_OPTION, array( 'user/rn_profile' => 1 ) );
|
||||
|
||||
TMDO_Native_Restorer::run( 'user', false );
|
||||
|
||||
$this->assertSame( array( '1' ), $this->native( 1, 'level' ), 'user 1 was done before the interruption' );
|
||||
$this->assertSame( array( '5' ), $this->native( 2, 'level' ) );
|
||||
}
|
||||
|
||||
public function test_catch_up_copies_rows_written_during_the_run(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'UPDATE `' . self::PROFILE . "` SET level = 9, updated_at = NOW() + INTERVAL 1 HOUR WHERE user_id = 1" );
|
||||
update_option( TMDO_Native_Restorer::CURSOR_OPTION, array( 'user/rn_profile' => 1 ) );
|
||||
|
||||
TMDO_Native_Restorer::run( 'user', false );
|
||||
|
||||
$this->assertSame( array( '9' ), $this->native( 1, 'level' ) );
|
||||
}
|
||||
|
||||
private function set_user_mode( string $mode ): void {
|
||||
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => $mode );
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Native is the source of truth outside aeav_only (e.g. WooCommerce writes
|
||||
* stock with direct SQL): the default policy there only fills gaps.
|
||||
*/
|
||||
public function test_native_authoritative_modes_only_fill_missing_rows(): void {
|
||||
$this->set_user_mode( 'dual_write' );
|
||||
|
||||
$report = TMDO_Native_Restorer::run( 'user', false );
|
||||
|
||||
$this->assertSame( array( '1' ), $this->native( 1, 'level' ), 'differing native value kept' );
|
||||
$this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ), 'missing native row filled' );
|
||||
$this->assertSame( array( '5' ), $this->native( 2, 'level' ) );
|
||||
$this->assertSame( array( 'stale' ), $this->native( 2, 'tags' ), 'NULL flat value does not delete native here' );
|
||||
$this->assertSame( 'only_missing', $report['policy'] );
|
||||
$this->assertSame( 2, $report['groups']['rn_profile']['missing'] );
|
||||
$this->assertSame( 2, $report['groups']['rn_profile']['differs'] );
|
||||
}
|
||||
|
||||
public function test_overwrite_policy_replaces_native_in_any_mode(): void {
|
||||
$this->set_user_mode( 'dual_write' );
|
||||
|
||||
TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'overwrite' );
|
||||
|
||||
$this->assertSame( array( '3' ), $this->native( 1, 'level' ) );
|
||||
$this->assertSame( array(), $this->native( 2, 'tags' ) );
|
||||
}
|
||||
|
||||
public function test_only_missing_policy_keeps_native_in_aeav_only(): void {
|
||||
TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'only_missing' );
|
||||
|
||||
$this->assertSame( array( '1' ), $this->native( 1, 'level' ) );
|
||||
$this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ) );
|
||||
}
|
||||
|
||||
public function test_dry_run_classifies_without_writing(): void {
|
||||
$report = TMDO_Native_Restorer::run( 'user', true );
|
||||
|
||||
$this->assertSame( 'overwrite', $report['policy'], 'aeav_only: flat is the source of truth' );
|
||||
$this->assertSame( 2, $report['groups']['rn_profile']['missing'] );
|
||||
$this->assertSame( 2, $report['groups']['rn_profile']['differs'] );
|
||||
$this->assertSame( 0, $report['groups']['rn_profile']['same'] );
|
||||
$this->assertSame( array( '1' ), $this->native( 1, 'level' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* A flat column holding its field default for an entity that never had
|
||||
* the key (e.g. activity.session_count = 0) is not written back.
|
||||
*/
|
||||
public function test_defaults_are_not_invented_as_native_rows(): void {
|
||||
global $wpdb;
|
||||
$table = 'wp_itest_wpdo_user_rn_counts';
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, visits BIGINT(20) DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( "INSERT INTO `{$table}` (user_id, visits, updated_at) VALUES (1, 0, '2020-01-01'), (2, 7, '2020-01-01')" );
|
||||
WPDO_Entity_Registry::register_group( 'user', 'rn_counts', array( array( 'key' => 'visits', 'type' => 'integer', 'default' => 0 ) ) );
|
||||
WPDO_Schema_Manager::flush_table_exists_cache();
|
||||
|
||||
try {
|
||||
$report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rn_counts' );
|
||||
|
||||
$this->assertSame( array(), $this->native( 1, 'visits' ), 'default 0 not invented' );
|
||||
$this->assertSame( array( '7' ), $this->native( 2, 'visits' ) );
|
||||
$this->assertSame( 1, $report['groups']['rn_counts']['default_skipped'] );
|
||||
} finally {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Orphaned flat-table groups stay readable.
|
||||
*
|
||||
* Scenario: a plugin registered an entity group, its flat table was created
|
||||
* (definition persisted in wpdo_registry_meta), data landed there, and then
|
||||
* the plugin went away. In aeav_only mode the values exist only in the flat
|
||||
* table, so without the group registration reads come back empty.
|
||||
*/
|
||||
class OrphanGroupRestoreTest extends TestCase {
|
||||
|
||||
private const GROUP = 'orphan_itest';
|
||||
private const TABLE = 'wp_itest_wpdo_user_orphan_itest';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-type-caster.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-mode-manager.php',
|
||||
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
'includes/engine/class-tmdo-hook-bus.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
WPDO_Installer::install_v2_tables();
|
||||
|
||||
global $wpdb;
|
||||
$wpdb->query(
|
||||
"CREATE TABLE IF NOT EXISTS `{$wpdb->usermeta}` (
|
||||
umeta_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
|
||||
meta_key VARCHAR(255) DEFAULT NULL,
|
||||
meta_value LONGTEXT,
|
||||
PRIMARY KEY (umeta_id),
|
||||
KEY meta_key (meta_key(191))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop_fixture();
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
$GLOBALS['_wp_transients'] = array();
|
||||
self::reset_statics();
|
||||
|
||||
// A provider registered the group earlier: its flat table exists and the
|
||||
// normalized definition was persisted to registry_meta (what
|
||||
// Schema_Manager::create_or_upgrade_table() leaves behind — built by hand
|
||||
// here because that method needs the real wp-admin upgrade.php).
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||
WPDO_Entity_Registry::register_group( 'user', self::GROUP, array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) );
|
||||
$wpdb->query(
|
||||
'CREATE TABLE `' . self::TABLE . '` (
|
||||
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT(20) NOT NULL,
|
||||
`orphan_note` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_entity` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||
);
|
||||
$wpdb->insert(
|
||||
$wpdb->prefix . 'wpdo_registry_meta',
|
||||
array(
|
||||
'entity_type' => 'user',
|
||||
'group_name' => self::GROUP,
|
||||
'schema_hash' => 'itest',
|
||||
'field_definitions' => wp_json_encode( WPDO_Entity_Registry::get_group_fields( 'user', self::GROUP ) ),
|
||||
)
|
||||
);
|
||||
$wpdb->insert( self::TABLE, array( 'user_id' => 77, 'orphan_note' => 'kept' ) );
|
||||
|
||||
// The provider goes away: next request starts with an empty registry.
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||
self::reset_statics();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop_fixture();
|
||||
WPDO_Entity_Registry::init();
|
||||
self::set_modes( null );
|
||||
}
|
||||
|
||||
public function test_orphaned_group_is_unreadable_before_restore(): void {
|
||||
self::set_modes( 'aeav_only' );
|
||||
|
||||
$this->assertNull( WPDO_Hook_Bus::intercept_get( null, 77, 'orphan_note', true, 'user' ) );
|
||||
}
|
||||
|
||||
public function test_restore_makes_aeav_only_data_readable_again(): void {
|
||||
self::set_modes( 'aeav_only' );
|
||||
|
||||
$this->assertSame( 1, WPDO_Schema_Manager::restore_orphaned_groups() );
|
||||
|
||||
$this->assertSame( array( 'kept' ), WPDO_Hook_Bus::intercept_get( null, 77, 'orphan_note', true, 'user' ) );
|
||||
$this->assertSame( array(), WPDO_Entity_Registry::get_pending_schemas(), 'Restoring must not queue DDL.' );
|
||||
}
|
||||
|
||||
public function test_restore_only_applies_to_aeav_only(): void {
|
||||
// dual_write / shadow_read still write native meta, so an orphaned key
|
||||
// keeps reading correctly from there — nothing to restore.
|
||||
foreach ( array( 'disabled', 'dual_write', 'shadow_read' ) as $mode ) {
|
||||
self::set_modes( $mode );
|
||||
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups(), $mode );
|
||||
$this->assertNull( WPDO_Entity_Registry::get_field( 'user', 'orphan_note' ), $mode );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_key_with_native_meta_rows_stays_on_native(): void {
|
||||
global $wpdb;
|
||||
self::set_modes( 'aeav_only' );
|
||||
// Written to native meta while the group was unregistered — newer than the flat copy.
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 5, 'meta_key' => 'orphan_note', 'meta_value' => 'native' ) );
|
||||
|
||||
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() );
|
||||
|
||||
$this->assertNull( WPDO_Hook_Bus::intercept_get( null, 5, 'orphan_note', true, 'user' ), 'Native meta must keep serving this key.' );
|
||||
$this->assertSame( array( 'orphan_note' ), WPDO_Entity_Registry::get_restored_groups()[0]['native'] );
|
||||
}
|
||||
|
||||
public function test_native_meta_check_is_cached(): void {
|
||||
self::set_modes( 'aeav_only' );
|
||||
WPDO_Schema_Manager::restore_orphaned_groups();
|
||||
|
||||
$this->assertSame( array( 'user:orphan_note' => false ), get_transient( 'tmdo_orphan_native_keys' ) );
|
||||
}
|
||||
|
||||
public function test_restore_skips_groups_whose_table_is_gone(): void {
|
||||
global $wpdb;
|
||||
self::set_modes( 'aeav_only' );
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||
self::reset_statics();
|
||||
|
||||
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() );
|
||||
}
|
||||
|
||||
public function test_registered_group_is_left_alone(): void {
|
||||
self::set_modes( 'aeav_only' );
|
||||
WPDO_Entity_Registry::register_group( 'user', self::GROUP, array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) );
|
||||
|
||||
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() );
|
||||
$this->assertSame( array(), WPDO_Entity_Registry::get_restored_groups() );
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static function drop_fixture(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => self::GROUP ) );
|
||||
$wpdb->delete( $wpdb->usermeta, array( 'meta_key' => 'orphan_note' ) );
|
||||
}
|
||||
|
||||
/** Clear Schema_Manager's table_exists cache and the Cache_Orchestrator L1. */
|
||||
private static function reset_statics(): void {
|
||||
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, array() );
|
||||
|
||||
$l1 = new ReflectionProperty( WPDO_Cache_Orchestrator::class, 'l1_cache' );
|
||||
$l1->setAccessible( true );
|
||||
$l1->setValue( null, array() );
|
||||
}
|
||||
|
||||
/** Inject the user mode straight into Mode_Manager's cache (null = reset). */
|
||||
private static function set_modes( ?string $user_mode ): void {
|
||||
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue(
|
||||
null,
|
||||
null === $user_mode ? null : array(
|
||||
'user' => $user_mode,
|
||||
'post' => 'disabled',
|
||||
'term' => 'disabled',
|
||||
'comment' => 'disabled',
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,38 @@ class PostBackfillJsonTest extends TestCase {
|
||||
$this->assertSame( 0, $result['errors'] ?? -1 );
|
||||
}
|
||||
|
||||
// ── backfill_group() / backfillable_groups() (2.2 PR-067) ───────────────
|
||||
|
||||
public function test_backfill_group_fills_json_columns_instead_of_skipping_them(): void {
|
||||
$this->seed_attachment( 1, array( 'width' => 640 ), 'Alt text' );
|
||||
|
||||
$result = WPDO_Post_Migration::backfill_group( 'attachment' );
|
||||
|
||||
$this->assertSame( 'attachment', $result['group'] );
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row( 'SELECT _wp_attachment_metadata, _wp_attachment_image_alt FROM `' . self::FLAT . '` WHERE post_id = 1', ARRAY_A );
|
||||
$this->assertSame( array( 'width' => 640 ), json_decode( (string) $row['_wp_attachment_metadata'], true ), 'json field must not be left NULL by the bulk pivot' );
|
||||
$this->assertSame( 'Alt text', $row['_wp_attachment_image_alt'] );
|
||||
}
|
||||
|
||||
public function test_backfillable_groups_are_the_registered_post_groups(): void {
|
||||
WPDO_Entity_Registry::register_group( 'post', 'itest_extra', array( array( 'key' => 'itest_extra_key', 'type' => 'text' ) ) );
|
||||
|
||||
$groups = WPDO_Post_Migration::backfillable_groups();
|
||||
|
||||
$this->assertSame( WPDO_Entity_Registry::get_groups_for_type( 'post' ), $groups );
|
||||
$this->assertContains( 'itest_extra', $groups, 'a group registered by an add-on must be offered for backfill' );
|
||||
}
|
||||
|
||||
public function test_restored_orphan_groups_are_never_backfilled(): void {
|
||||
WPDO_Entity_Registry::restore_group( 'post', 'itest_orphan', array( array( 'key' => 'itest_orphan_key', 'type' => 'text' ) ) );
|
||||
$this->assertContains( 'itest_orphan', WPDO_Entity_Registry::get_groups_for_type( 'post' ) );
|
||||
|
||||
$this->assertNotContains( 'itest_orphan', WPDO_Post_Migration::backfillable_groups(), 'orphan data lives only in the flat table' );
|
||||
$this->expectException( InvalidArgumentException::class );
|
||||
WPDO_Post_Migration::backfill_group( 'itest_orphan' );
|
||||
}
|
||||
|
||||
public function test_returns_error_for_unknown_group(): void {
|
||||
$result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' );
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ class PostEntityLifecycleTest extends TestCase {
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Post_Fields::register_entity_fields();
|
||||
tmdo_test_register_wc_product_group();
|
||||
}
|
||||
|
||||
public static function tearDownAfterClass(): void {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Moving values written under legacy routing into their post type's group.
|
||||
*
|
||||
* Fixture (legacy owner of `shared_key` = rc_vendor, the last group registered):
|
||||
* listing 101: vendor table 'live' | listing table 'backfill' (+ price 5)
|
||||
* listing 102: vendor table 'only-in-vendor' | no listing row
|
||||
* listing 103: no vendor row | listing table 'keep'
|
||||
* listing 104: vendor row with NULL | listing table 'backfill-104'
|
||||
* vendor 202: vendor table 'vendor own value'
|
||||
*
|
||||
* Reads before the switch returned the vendor-table value for 101/102, so
|
||||
* those must win; 103/104 were never written after the backfill, so the
|
||||
* listing value stays.
|
||||
*/
|
||||
class PostGroupReconcilerTest extends TestCase {
|
||||
|
||||
private const LISTING = 'wp_itest_wpdo_post_rc_listing';
|
||||
private const VENDOR = 'wp_itest_wpdo_post_rc_vendor';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||
'includes/adapters/class-tmdo-adapter-post.php',
|
||||
'includes/migration/class-tmdo-post-group-reconciler.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||
foreach ( array( 101 => 'hp_listing', 102 => 'hp_listing', 103 => 'hp_listing', 104 => 'hp_listing', 202 => 'hp_vendor' ) as $id => $type ) {
|
||||
$wpdb->insert( $wpdb->posts, array( 'ID' => $id, 'post_type' => $type ) );
|
||||
}
|
||||
$wpdb->query( 'CREATE TABLE `' . self::LISTING . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, price DECIMAL(18,6) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::VENDOR . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->insert( self::VENDOR, array( 'post_id' => 101, 'shared_key' => 'live' ) );
|
||||
$wpdb->insert( self::VENDOR, array( 'post_id' => 102, 'shared_key' => 'only-in-vendor' ) );
|
||||
$wpdb->query( 'INSERT INTO `' . self::VENDOR . "` (post_id, shared_key) VALUES (104, NULL)" );
|
||||
$wpdb->insert( self::VENDOR, array( 'post_id' => 202, 'shared_key' => 'vendor own value' ) );
|
||||
$wpdb->insert( self::LISTING, array( 'post_id' => 101, 'shared_key' => 'backfill', 'price' => 5 ) );
|
||||
$wpdb->insert( self::LISTING, array( 'post_id' => 103, 'shared_key' => 'keep' ) );
|
||||
$wpdb->insert( self::LISTING, array( 'post_id' => 104, 'shared_key' => 'backfill-104' ) );
|
||||
// Historical rows: older than any run, so the catch-up pass leaves them alone.
|
||||
$wpdb->query( 'UPDATE `' . self::VENDOR . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||
$wpdb->query( 'UPDATE `' . self::LISTING . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||
|
||||
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '0' );
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'rc_listing', array( array( 'key' => 'shared_key', 'type' => 'text' ), array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'rc_vendor', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( $wpdb->posts, self::LISTING, self::VENDOR ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
private function listing_value( int $post_id ): ?string {
|
||||
global $wpdb;
|
||||
return $wpdb->get_var( $wpdb->prepare( 'SELECT shared_key FROM `' . self::LISTING . '` WHERE post_id = %d', $post_id ) );
|
||||
}
|
||||
|
||||
public function test_dry_run_reports_without_writing(): void {
|
||||
$report = TMDO_Post_Group_Reconciler::run( true );
|
||||
|
||||
$this->assertSame( 2, $report['moves'][0]['copied'], '101 and 102 hold non-NULL legacy values' );
|
||||
$this->assertSame( 1, $report['moves'][0]['overwritten'], '101 has a different listing value' );
|
||||
$this->assertFalse( $report['enabled'] );
|
||||
$this->assertSame( 'backfill', $this->listing_value( 101 ) );
|
||||
$this->assertNull( $this->listing_value( 102 ) );
|
||||
$this->assertSame( '0', get_option( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ) );
|
||||
}
|
||||
|
||||
public function test_run_moves_the_values_reads_returned_before_the_switch(): void {
|
||||
$report = TMDO_Post_Group_Reconciler::run( false, 1 );
|
||||
|
||||
$this->assertSame( 'live', $this->listing_value( 101 ) );
|
||||
$this->assertSame( 'only-in-vendor', $this->listing_value( 102 ) );
|
||||
$this->assertSame( 'keep', $this->listing_value( 103 ) );
|
||||
$this->assertSame( 'backfill-104', $this->listing_value( 104 ), 'NULL legacy values do not erase the listing value' );
|
||||
$this->assertNull( $this->listing_value( 202 ), 'vendor posts are not copied into the listing table' );
|
||||
$this->assertSame( 2, $report['moves'][0]['copied'] );
|
||||
}
|
||||
|
||||
public function test_other_columns_of_the_target_row_are_untouched(): void {
|
||||
global $wpdb;
|
||||
TMDO_Post_Group_Reconciler::run( false );
|
||||
|
||||
$this->assertSame( 5.0, (float) $wpdb->get_var( 'SELECT price FROM `' . self::LISTING . '` WHERE post_id = 101' ) );
|
||||
}
|
||||
|
||||
public function test_completion_turns_scoped_routing_on_and_clears_the_cursor(): void {
|
||||
$report = TMDO_Post_Group_Reconciler::run( false );
|
||||
|
||||
$this->assertTrue( $report['enabled'] );
|
||||
$this->assertSame( '1', get_option( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ) );
|
||||
$this->assertFalse( get_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION ) );
|
||||
}
|
||||
|
||||
public function test_catch_up_copies_rows_written_during_the_run(): void {
|
||||
global $wpdb;
|
||||
// Simulates a legacy-routed write that lands after the batch passed post 101.
|
||||
$wpdb->query( 'UPDATE `' . self::VENDOR . "` SET shared_key = 'written during run', updated_at = NOW() + INTERVAL 1 HOUR WHERE post_id = 101" );
|
||||
update_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION, array( 'rc_vendor>rc_listing:shared_key' => 101 ) );
|
||||
|
||||
TMDO_Post_Group_Reconciler::run( false );
|
||||
|
||||
$this->assertSame( 'written during run', $this->listing_value( 101 ) );
|
||||
}
|
||||
|
||||
public function test_resumes_after_the_stored_cursor(): void {
|
||||
update_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION, array( 'rc_vendor>rc_listing:shared_key' => 101 ) );
|
||||
|
||||
TMDO_Post_Group_Reconciler::run( false );
|
||||
|
||||
$this->assertSame( 'backfill', $this->listing_value( 101 ), 'rows up to the cursor were already done' );
|
||||
$this->assertSame( 'only-in-vendor', $this->listing_value( 102 ) );
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ class PostMigrationTest extends TestCase {
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Post_Fields::register_entity_fields();
|
||||
tmdo_test_register_wc_product_group();
|
||||
}
|
||||
|
||||
public static function tearDownAfterClass(): void {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Routed searches filter status and sort by date on the flat table's
|
||||
* post_status / post_date copies (O-1), when those copies are trustworthy.
|
||||
*/
|
||||
class PostQueryRouterCopyTest extends TestCase {
|
||||
|
||||
private const TABLE = 'wp_itest_wpdo_post_rc_core';
|
||||
private const ALIAS = '`wpdoflat_rc_item_rc_core`';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-mode-manager.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/adapters/class-tmdo-adapter-post.php',
|
||||
'includes/integrations/class-tmdo-post-fields.php',
|
||||
'includes/engine/class-tmdo-post-status-sync.php',
|
||||
'includes/query/class-tmdo-post-query-router.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date (post_status, post_date) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( 'INSERT INTO `' . self::TABLE . "` (post_id, rc_price, post_status, post_date) VALUES (1, 10, 'publish', '2026-01-01 00:00:00')" );
|
||||
|
||||
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'ready' ) );
|
||||
$GLOBALS['_wp_is_admin'] = false;
|
||||
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, array() );
|
||||
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$mode->setAccessible( true );
|
||||
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
TMDO_Post_Query_Router::flush_copy_cache();
|
||||
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'rc_core', array( array( 'key' => 'rc_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'rc_item' ) ) );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||
WPDO_Entity_Registry::init();
|
||||
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$mode->setAccessible( true );
|
||||
$mode->setValue( null, null );
|
||||
}
|
||||
|
||||
/** @return array{join:string, where:string, orderby:string} */
|
||||
private function sql( array $vars ): array {
|
||||
$query = new WP_Query();
|
||||
foreach ( array_merge(
|
||||
array(
|
||||
'post_type' => 'rc_item',
|
||||
'post_status' => 'publish',
|
||||
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
|
||||
),
|
||||
$vars
|
||||
) as $k => $v ) {
|
||||
$query->set( $k, $v );
|
||||
}
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
$router->pre_get_posts( $query );
|
||||
return array(
|
||||
'join' => $router->posts_join( '', $query ),
|
||||
'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ),
|
||||
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
|
||||
);
|
||||
}
|
||||
|
||||
private function assert_legacy_shape( array $sql ): void {
|
||||
$this->assertStringContainsString( 'LEFT JOIN', $sql['join'] );
|
||||
$this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] );
|
||||
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||
}
|
||||
|
||||
public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void {
|
||||
$sql = $this->sql( array( 'order' => 'ASC' ) );
|
||||
|
||||
$this->assertStringContainsString( 'INNER JOIN', $sql['join'] );
|
||||
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] );
|
||||
$this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' );
|
||||
$this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] );
|
||||
}
|
||||
|
||||
public function test_several_explicit_statuses_are_kept(): void {
|
||||
$sql = $this->sql( array( 'post_status' => array( 'publish', 'private' ), 'orderby' => 'date' ) );
|
||||
|
||||
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish','private')", $sql['where'] );
|
||||
$this->assertSame( self::ALIAS . '.`post_date` DESC', $sql['orderby'] );
|
||||
}
|
||||
|
||||
public function test_copies_not_marked_ready_keep_the_legacy_shape(): void {
|
||||
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'columns' ) );
|
||||
|
||||
$this->assert_legacy_shape( $this->sql( array() ) );
|
||||
}
|
||||
|
||||
public function test_rows_with_missing_copies_keep_the_legacy_shape(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, rc_price) VALUES (2, 12)' );
|
||||
|
||||
$this->assert_legacy_shape( $this->sql( array() ) );
|
||||
}
|
||||
|
||||
public function test_other_orderings_or_implicit_statuses_keep_the_legacy_shape(): void {
|
||||
$this->assert_legacy_shape( $this->sql( array( 'orderby' => 'title' ) ) );
|
||||
$this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) );
|
||||
$this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) );
|
||||
}
|
||||
|
||||
public function test_not_exists_keeps_the_legacy_shape(): void {
|
||||
// The INNER JOIN would drop posts without a flat row, which NOT EXISTS must match.
|
||||
$this->assert_legacy_shape( $this->sql( array( 'meta_query' => array( array( 'key' => 'rc_price', 'compare' => 'NOT EXISTS' ) ) ) ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The post query router leaves a query native while the last drift-check
|
||||
* found a routed clause's group out of step with native meta (2.2 PR-072).
|
||||
* All or nothing: one drifting group keeps the whole query native. A group
|
||||
* never checked keeps routing (decision 2026-09-27).
|
||||
*/
|
||||
class PostQueryRouterDriftTest extends TestCase {
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Post_Fields::register_entity_fields();
|
||||
tmdo_test_register_wc_product_group();
|
||||
}
|
||||
|
||||
public static function tearDownAfterClass(): void {
|
||||
self::set_modes( null );
|
||||
WPDO_Entity_Registry::init();
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
self::set_modes(
|
||||
array(
|
||||
'post' => 'shadow_read',
|
||||
'user' => 'disabled',
|
||||
'term' => 'disabled',
|
||||
'comment' => 'disabled',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static function set_modes( ?array $modes ): void {
|
||||
$cache = ( new ReflectionClass( WPDO_Mode_Manager::class ) )->getProperty( 'cache' );
|
||||
$cache->setAccessible( true );
|
||||
$cache->setValue( null, $modes );
|
||||
}
|
||||
|
||||
private function drift( string $group, int $diff ): void {
|
||||
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ]['post'][ $group ] = array(
|
||||
'miss' => 0,
|
||||
'diff' => $diff,
|
||||
'extra' => 0,
|
||||
'sample' => 0,
|
||||
'checked_at' => '2026-09-27 00:00:00',
|
||||
);
|
||||
}
|
||||
|
||||
private function price_query( array $extra = array() ): WP_Query {
|
||||
$query = new WP_Query();
|
||||
foreach ( array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||
),
|
||||
) + $extra as $k => $v ) {
|
||||
$query->set( $k, $v );
|
||||
}
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function test_a_drifting_group_keeps_the_query_native(): void {
|
||||
$this->drift( 'wc_product', 3 );
|
||||
$query = $this->price_query();
|
||||
$original = $query->get( 'meta_query' );
|
||||
|
||||
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||
|
||||
$this->assertSame( $original, $query->get( 'meta_query' ) );
|
||||
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
|
||||
}
|
||||
|
||||
public function test_a_clean_group_is_routed(): void {
|
||||
$this->drift( 'wc_product', 0 );
|
||||
$query = $this->price_query();
|
||||
|
||||
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||
|
||||
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
||||
}
|
||||
|
||||
public function test_a_group_never_checked_is_routed(): void {
|
||||
$query = $this->price_query();
|
||||
|
||||
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||
|
||||
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
||||
}
|
||||
|
||||
public function test_drift_in_another_group_does_not_matter(): void {
|
||||
$this->drift( 'hp_listing_core', 5 );
|
||||
$query = $this->price_query();
|
||||
|
||||
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||
|
||||
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
||||
}
|
||||
|
||||
public function test_aeav_only_ignores_drift_because_native_is_stale_by_design(): void {
|
||||
self::set_modes(
|
||||
array(
|
||||
'post' => 'aeav_only',
|
||||
'user' => 'disabled',
|
||||
'term' => 'disabled',
|
||||
'comment' => 'disabled',
|
||||
)
|
||||
);
|
||||
$this->drift( 'wc_product', 3 );
|
||||
$query = $this->price_query();
|
||||
|
||||
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||
|
||||
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
||||
}
|
||||
|
||||
public function test_sorting_by_a_drifting_key_stays_native(): void {
|
||||
$this->drift( 'wc_product', 1 );
|
||||
$query = new WP_Query();
|
||||
$query->set( 'post_type', 'product' );
|
||||
$query->set( 'meta_key', '_price' );
|
||||
$query->set( 'orderby', 'meta_value_num' );
|
||||
|
||||
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||
|
||||
$this->assertSame( '_price', $query->get( 'meta_key' ) );
|
||||
$this->assertSame( '', $query->get( 'wpdo_post_sort' ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Routed searches drop SQL_CALC_FOUND_ROWS; the total comes from a COUNT
|
||||
* cached until posts' last_changed moves (O-4).
|
||||
*
|
||||
* Three fr_item posts, prices 10 / 20 / 30.
|
||||
*/
|
||||
class PostQueryRouterFoundRowsTest extends TestCase {
|
||||
|
||||
private const TABLE = 'wp_itest_wpdo_post_fr_core';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-type-caster.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-mode-manager.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||
'includes/adapters/class-tmdo-adapter-post.php',
|
||||
'includes/integrations/class-tmdo-post-fields.php',
|
||||
'includes/engine/class-tmdo-hook-bus.php',
|
||||
'includes/engine/class-tmdo-post-status-sync.php',
|
||||
'includes/query/class-tmdo-post-query-router.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, fr_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type) VALUES (1, 'fr_item'), (2, 'fr_item'), (3, 'fr_item')" );
|
||||
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, fr_price) VALUES (1, 10), (2, 20), (3, 30)' );
|
||||
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
$GLOBALS['_wp_last_changed'] = array();
|
||||
$GLOBALS['_wp_is_admin'] = false;
|
||||
$GLOBALS['_wp_current_filter'] = '';
|
||||
$GLOBALS['_wp_post_types'] = array( 1 => 'fr_item', 2 => 'fr_item', 3 => 'fr_item' );
|
||||
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, array() );
|
||||
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$mode->setAccessible( true );
|
||||
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
TMDO_Post_Query_Router::flush_copy_cache();
|
||||
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'fr_core', array( array( 'key' => 'fr_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'fr_item' ) ) );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$mode->setAccessible( true );
|
||||
$mode->setValue( null, null );
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( $wpdb->posts, self::TABLE ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
/** A routed query plus the SQL WordPress would build for it (page size 1). */
|
||||
private function routed( array $clause = array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=', 'type' => 'NUMERIC' ) ): array {
|
||||
global $wpdb;
|
||||
$query = new WP_Query();
|
||||
$query->set( 'post_type', 'fr_item' );
|
||||
$query->set( 'post_status', 'publish' );
|
||||
$query->set( 'meta_query', array( $clause ) );
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
$router->pre_get_posts( $query );
|
||||
|
||||
$request = "SELECT SQL_CALC_FOUND_ROWS {$wpdb->posts}.ID FROM {$wpdb->posts} " . $router->posts_join( '', $query )
|
||||
. " WHERE 1=1 AND {$wpdb->posts}.post_type = 'fr_item' AND (({$wpdb->posts}.post_status = 'publish'))" . $router->posts_where( '', $query )
|
||||
. " GROUP BY {$wpdb->posts}.ID ORDER BY {$wpdb->posts}.post_date DESC LIMIT 0, 1";
|
||||
return array( $router, $query, $request );
|
||||
}
|
||||
|
||||
public function test_a_group_waiting_for_its_table_upgrade_is_not_routed(): void {
|
||||
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$mode->setAccessible( true );
|
||||
$mode->setValue( null, array( 'post' => 'shadow_read', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'post/fr_core' => time() ) );
|
||||
|
||||
list( , $query ) = $this->routed();
|
||||
|
||||
$this->assertEmpty( $query->get( 'wpdo_post_clauses' ), 'kept native' );
|
||||
}
|
||||
|
||||
public function test_routed_request_drops_sql_calc_found_rows(): void {
|
||||
list( $router, $query, $request ) = $this->routed();
|
||||
|
||||
$sql = $router->posts_request( $request, $query );
|
||||
|
||||
$this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $sql );
|
||||
$this->assertStringContainsString( 'LIMIT 0, 1', $sql );
|
||||
}
|
||||
|
||||
public function test_unrouted_request_is_left_alone(): void {
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
$request = 'SELECT SQL_CALC_FOUND_ROWS wp_itest_posts.ID FROM wp_itest_posts WHERE 1=1 LIMIT 0, 10';
|
||||
|
||||
$this->assertSame( $request, $router->posts_request( $request, new WP_Query() ) );
|
||||
$this->assertSame( 'SELECT FOUND_ROWS()', $router->found_posts_query( 'SELECT FOUND_ROWS()', new WP_Query() ) );
|
||||
}
|
||||
|
||||
public function test_total_is_counted_once_and_served_from_cache_until_posts_change(): void {
|
||||
global $wpdb;
|
||||
list( $router, $query, $request ) = $this->routed();
|
||||
$router->posts_request( $request, $query );
|
||||
|
||||
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) );
|
||||
|
||||
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 2' );
|
||||
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'cached' );
|
||||
|
||||
wp_cache_set_posts_last_changed(); // Any post meta write, e.g. a theme's CSS descriptor on a page view.
|
||||
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'still cached' );
|
||||
|
||||
TMDO_Post_Query_Router::bump_count_version(); // A post saved, or a managed key written.
|
||||
$this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' );
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress excludes a different random set of posts on each search page;
|
||||
* the total is the cached count without the exclusion minus a live count
|
||||
* of the excluded posts that match (by primary key).
|
||||
*/
|
||||
public function test_excluded_posts_do_not_split_the_cached_total(): void {
|
||||
global $wpdb;
|
||||
$count = function ( string $not_in ) use ( $wpdb ): string {
|
||||
list( $router, $query, $request ) = $this->routed();
|
||||
$request = str_replace( ' GROUP BY', " AND {$wpdb->posts}.ID NOT IN ({$not_in}) GROUP BY", $request );
|
||||
$router->posts_request( $request, $query );
|
||||
return (string) $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) );
|
||||
};
|
||||
|
||||
$this->assertSame( '1', $count( '3' ), 'prices 20, 30 match; 3 excluded' );
|
||||
$this->assertSame( '0', $count( '2,3' ) );
|
||||
|
||||
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 3' ); // Not seen until posts change.
|
||||
$this->assertSame( '2', $count( '1' ), 'one cached count serves every exclusion list; 1 does not match' );
|
||||
$this->assertSame( '1', $count( '2' ) );
|
||||
|
||||
TMDO_Post_Query_Router::bump_count_version();
|
||||
$this->assertSame( '0', $count( '2' ), 'recounted' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyword searches carry wpdb's placeholder escape for "%", which changes
|
||||
* with every request: the cache key uses the SQL without it, or no search
|
||||
* with a keyword ever hits the cached count.
|
||||
*/
|
||||
public function test_the_cached_count_survives_a_new_placeholder_escape(): void {
|
||||
global $wpdb;
|
||||
$real = $wpdb;
|
||||
$wpdb = new class( $real ) {
|
||||
public int $counts = 0;
|
||||
|
||||
public function __construct( private object $db ) {}
|
||||
|
||||
public function remove_placeholder_escape( string $sql ): string {
|
||||
return (string) preg_replace( '/\{[0-9a-f]{8}\}/', '%', $sql );
|
||||
}
|
||||
|
||||
public function __call( string $name, array $args ) {
|
||||
if ( 'get_var' === $name && str_contains( (string) $args[0], 'COUNT(*)' ) ) {
|
||||
++$this->counts;
|
||||
}
|
||||
return $this->db->$name( ...$args );
|
||||
}
|
||||
|
||||
public function __get( string $name ) {
|
||||
return $this->db->$name;
|
||||
}
|
||||
};
|
||||
try {
|
||||
foreach ( array( '{1a2b3c4d}', '{9f8e7d6c}' ) as $escape ) { // Two requests.
|
||||
list( $router, $query, $request ) = $this->routed();
|
||||
$request = str_replace( ' GROUP BY', " AND '{$escape}' = '{$escape}' GROUP BY", $request );
|
||||
$router->posts_request( $request, $query );
|
||||
$this->assertSame( '2', $real->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) );
|
||||
}
|
||||
$counts = $wpdb->counts;
|
||||
} finally {
|
||||
$wpdb = $real;
|
||||
}
|
||||
|
||||
$this->assertSame( 1, $counts, 'counted once' );
|
||||
}
|
||||
|
||||
public function test_flat_writes_and_deletes_move_posts_last_changed(): void {
|
||||
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
||||
$before = wp_cache_get_last_changed( 'posts' );
|
||||
$version = wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP );
|
||||
WPDO_Hook_Bus::intercept_update( null, 1, 'fr_price', '11', '' );
|
||||
$after_write = wp_cache_get_last_changed( 'posts' );
|
||||
|
||||
$GLOBALS['_wp_current_filter'] = 'delete_post_metadata';
|
||||
WPDO_Hook_Bus::intercept_delete( null, 1, 'fr_price', '', false );
|
||||
|
||||
$this->assertNotSame( $before, $after_write );
|
||||
$this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) );
|
||||
$this->assertNotSame( $version, wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP ), 'routed counts follow flat writes' );
|
||||
}
|
||||
|
||||
// ── flat-first: a filtered search ordered by a flat column ─────────────
|
||||
|
||||
/**
|
||||
* A routed search ordered by the flat price (named clause), with the
|
||||
* posts.ID tie-break a plugin such as HivePress appends.
|
||||
*
|
||||
* @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string}
|
||||
*/
|
||||
private function sorted( string $compare = '>=' ): array {
|
||||
global $wpdb;
|
||||
$query = new WP_Query();
|
||||
$query->set( 'post_type', 'fr_item' );
|
||||
$query->set( 'post_status', 'publish' );
|
||||
$query->set( 'meta_query', array( 'price' => array( 'key' => 'fr_price', 'value' => 15, 'compare' => $compare, 'type' => 'NUMERIC' ) ) );
|
||||
$query->set( 'orderby', 'price' );
|
||||
$query->set( 'order', 'DESC' );
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
$router->pre_get_posts( $query );
|
||||
|
||||
$request = "SELECT SQL_CALC_FOUND_ROWS {$wpdb->posts}.ID FROM {$wpdb->posts} " . $router->posts_join( '', $query )
|
||||
. " WHERE 1=1 AND {$wpdb->posts}.post_type = 'fr_item' AND (({$wpdb->posts}.post_status = 'publish'))" . $router->posts_where( '', $query )
|
||||
. ' ORDER BY ' . $router->posts_orderby( '', $query ) . ", {$wpdb->posts}.ID DESC LIMIT 0, 10";
|
||||
return array( $router, $query, $request );
|
||||
}
|
||||
|
||||
public function test_sorted_filtered_request_drives_from_the_flat_table_with_the_same_rows(): void {
|
||||
global $wpdb;
|
||||
list( $router, $query, $request ) = $this->sorted();
|
||||
|
||||
$sql = $router->posts_request( $request, $query );
|
||||
|
||||
$this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql );
|
||||
$this->assertStringContainsString( 'FROM `' . self::TABLE . '` AS `wpdoflat_fr_item_fr_core` INNER JOIN ' . $wpdb->posts . ' ON', $sql );
|
||||
$this->assertStringNotContainsString( 'LEFT JOIN `' . self::TABLE . '`', $sql );
|
||||
$this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id' );
|
||||
$this->assertSame( array( '3', '2' ), $wpdb->get_col( $sql ) );
|
||||
$this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $request ) ), $wpdb->get_col( $sql ) );
|
||||
}
|
||||
|
||||
public function test_date_ordered_filtered_request_keeps_wordpress_join_order(): void {
|
||||
list( $router, $query, $request ) = $this->routed();
|
||||
|
||||
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
|
||||
}
|
||||
|
||||
public function test_flat_first_needs_a_condition_that_rejects_posts_without_a_flat_row(): void {
|
||||
list( $router, $query, $request ) = $this->sorted( 'NOT EXISTS' );
|
||||
|
||||
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
|
||||
}
|
||||
|
||||
public function test_flat_first_is_skipped_for_selective_post_filters(): void {
|
||||
list( $router, $query, $request ) = $this->sorted();
|
||||
$query->set( 'post__in', array( 2 ) );
|
||||
|
||||
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
|
||||
}
|
||||
|
||||
public function test_flat_first_can_be_turned_off(): void {
|
||||
list( $router, $query, $request ) = $this->sorted();
|
||||
$off = static fn() => false;
|
||||
add_filter( 'tmdo_router_flat_first', $off );
|
||||
try {
|
||||
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
|
||||
} finally {
|
||||
remove_filter( 'tmdo_router_flat_first', $off );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_flat_first_keeps_the_split_query(): void {
|
||||
global $wpdb;
|
||||
list( $router, $query, $request ) = $this->sorted();
|
||||
$old_request = str_replace( "{$wpdb->posts}.ID FROM", "{$wpdb->posts}.* FROM", $request );
|
||||
$query->request = $router->posts_request( $old_request, $query );
|
||||
$query->set( 'posts_per_page', 10 );
|
||||
|
||||
$this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $query->request );
|
||||
$this->assertTrue( $router->split_the_query( false, $query, $old_request, array( 'fields' => "{$wpdb->posts}.*", 'limits' => 'LIMIT 0, 10' ) ) );
|
||||
}
|
||||
|
||||
// ── split query: IDs first, then cached post objects ──────────────────
|
||||
|
||||
/**
|
||||
* WordPress's full-row request for a routed query, before and after posts_request.
|
||||
*
|
||||
* @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string, 3:array<string, string>}
|
||||
*/
|
||||
private function full_rows(): array {
|
||||
global $wpdb;
|
||||
list( $router, $query, $request ) = $this->routed();
|
||||
$old_request = str_replace( "{$wpdb->posts}.ID FROM", "{$wpdb->posts}.* FROM", $request );
|
||||
$query->request = $router->posts_request( $old_request, $query );
|
||||
$query->set( 'posts_per_page', 1 );
|
||||
return array( $router, $query, $old_request, array( 'fields' => "{$wpdb->posts}.*", 'limits' => 'LIMIT 0, 1' ) );
|
||||
}
|
||||
|
||||
public function test_routed_full_row_request_is_still_split(): void {
|
||||
list( $router, $query, $old_request, $clauses ) = $this->full_rows();
|
||||
|
||||
$this->assertTrue( $router->split_the_query( false, $query, $old_request, $clauses ) );
|
||||
}
|
||||
|
||||
public function test_request_changed_by_others_is_not_split(): void {
|
||||
list( $router, $query, $old_request, $clauses ) = $this->full_rows();
|
||||
$query->request .= ' /* another plugin */';
|
||||
|
||||
$this->assertFalse( $router->split_the_query( false, $query, $old_request, $clauses ) );
|
||||
}
|
||||
|
||||
public function test_unrouted_query_keeps_the_core_decision(): void {
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
|
||||
$this->assertFalse( $router->split_the_query( false, new WP_Query(), 'SELECT 1', array( 'fields' => 'wp_itest_posts.*', 'limits' => 'LIMIT 0, 1' ) ) );
|
||||
$this->assertTrue( $router->split_the_query( true, new WP_Query(), 'SELECT 1', array() ) );
|
||||
}
|
||||
|
||||
public function test_split_id_request_goes_through_posts_request_too(): void {
|
||||
unset( $GLOBALS['_wp_filter_callbacks']['posts_request_ids'], $GLOBALS['_wp_filter_callbacks']['split_the_query'] );
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
$router->register_hooks();
|
||||
|
||||
$this->assertContains( array( $router, 'posts_request' ), $GLOBALS['_wp_filter_callbacks']['posts_request_ids'] ?? array() );
|
||||
$this->assertContains( array( $router, 'split_the_query' ), $GLOBALS['_wp_filter_callbacks']['split_the_query'] ?? array() );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Meta orderby on the flat table: the "sort by an attribute, keep posts
|
||||
* without it" shape (relation OR of NOT EXISTS + EXISTS on one key, named
|
||||
* clause in orderby — HivePress search sorting), orderby on a named routed
|
||||
* clause, and meta_key + meta_value(_num).
|
||||
*
|
||||
* Only when the flat column orders the way native CASTs meta_value;
|
||||
* otherwise the whole query stays native.
|
||||
*/
|
||||
class PostQueryRouterOrderTest extends TestCase {
|
||||
|
||||
private const ALIAS = '`wpdoflat_product_wc_product`';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Post_Fields::register_entity_fields();
|
||||
tmdo_test_register_wc_product_group();
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
$cache = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$cache->setAccessible( true );
|
||||
$cache->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
}
|
||||
|
||||
public static function tearDownAfterClass(): void {
|
||||
$cache = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$cache->setAccessible( true );
|
||||
$cache->setValue( null, null );
|
||||
WPDO_Entity_Registry::init();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $vars Query vars (post_type defaults to product).
|
||||
* @return array{query:WP_Query, join:string, where:string, orderby:string}
|
||||
*/
|
||||
private function route( array $vars ): array {
|
||||
$query = new WP_Query();
|
||||
foreach ( $vars + array( 'post_type' => 'product' ) as $k => $v ) {
|
||||
$query->set( $k, $v );
|
||||
}
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
$router->pre_get_posts( $query );
|
||||
return array(
|
||||
'query' => $query,
|
||||
'join' => $router->posts_join( '', $query ),
|
||||
'where' => $router->posts_where( '', $query ),
|
||||
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type Clause type.
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
private function sort_shape( string $type ): array {
|
||||
return array(
|
||||
array(
|
||||
'relation' => 'OR',
|
||||
'price__order' => array( 'key' => '_price', 'type' => $type, 'compare' => 'NOT EXISTS' ),
|
||||
array( 'key' => '_price', 'type' => $type, 'compare' => 'EXISTS' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_sort_shape_left_joins_and_orders_by_the_flat_column_without_filtering(): void {
|
||||
$sql = $this->route( array( 'meta_query' => $this->sort_shape( 'DECIMAL' ), 'orderby' => 'price__order', 'order' => 'ASC' ) );
|
||||
|
||||
$this->assertSame( array(), $sql['query']->get( 'meta_query' ), 'sort group removed from the native meta_query' );
|
||||
$this->assertStringContainsString( 'LEFT JOIN `wp_itest_wpdo_post_wc_product` AS ' . self::ALIAS, $sql['join'] );
|
||||
$this->assertStringNotContainsString( '_price', $sql['where'], 'every post matches: no condition' );
|
||||
$this->assertStringStartsWith( self::ALIAS . '.`_price` ASC', $sql['orderby'] );
|
||||
}
|
||||
|
||||
public function test_sort_shape_with_char_type_on_a_numeric_column_stays_native(): void {
|
||||
$vars = array( 'meta_query' => $this->sort_shape( 'CHAR' ), 'orderby' => 'price__order', 'order' => 'ASC' );
|
||||
$sql = $this->route( $vars );
|
||||
|
||||
$this->assertSame( $vars['meta_query'], $sql['query']->get( 'meta_query' ) );
|
||||
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||
}
|
||||
|
||||
public function test_orderby_on_a_named_routed_clause_orders_by_its_column(): void {
|
||||
$sql = $this->route(
|
||||
array(
|
||||
'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ),
|
||||
'orderby' => array( 'price' => 'DESC' ),
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertStringContainsString( self::ALIAS . '.`_price` IS NOT NULL', $sql['where'] );
|
||||
$this->assertStringStartsWith( self::ALIAS . '.`_price` DESC', $sql['orderby'] );
|
||||
}
|
||||
|
||||
public function test_orderby_naming_a_routed_clause_among_other_keys_stays_native(): void {
|
||||
$vars = array(
|
||||
'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ),
|
||||
'orderby' => array( 'price' => 'DESC', 'title' => 'ASC' ),
|
||||
);
|
||||
$sql = $this->route( $vars );
|
||||
|
||||
$this->assertSame( $vars['meta_query'], $sql['query']->get( 'meta_query' ) );
|
||||
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||
}
|
||||
|
||||
public function test_meta_value_num_on_a_numeric_key_requires_the_key_and_orders_by_its_column(): void {
|
||||
$sql = $this->route( array( 'meta_key' => '_price', 'orderby' => 'meta_value_num', 'order' => 'DESC' ) );
|
||||
|
||||
$this->assertSame( '', $sql['query']->get( 'meta_key' ), 'primary meta clause taken over' );
|
||||
$this->assertStringContainsString( self::ALIAS . '.`_price` IS NOT NULL', $sql['where'], 'native INNER JOIN drops posts without the key' );
|
||||
$this->assertStringStartsWith( self::ALIAS . '.`_price` DESC', $sql['orderby'] );
|
||||
}
|
||||
|
||||
public function test_meta_value_on_a_string_key_is_routed(): void {
|
||||
$sql = $this->route( array( 'meta_key' => '_sku', 'orderby' => 'meta_value', 'order' => 'ASC' ) );
|
||||
|
||||
$this->assertStringStartsWith( self::ALIAS . '.`_sku` ASC', $sql['orderby'] );
|
||||
}
|
||||
|
||||
public function test_meta_value_on_a_numeric_key_stays_native(): void {
|
||||
$sql = $this->route( array( 'meta_key' => '_price', 'orderby' => 'meta_value', 'order' => 'ASC' ) );
|
||||
|
||||
$this->assertSame( '_price', $sql['query']->get( 'meta_key' ), 'native string order' );
|
||||
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||
}
|
||||
|
||||
public function test_meta_key_with_a_meta_value_filter_stays_native(): void {
|
||||
$sql = $this->route( array( 'meta_key' => '_price', 'meta_value' => '5', 'orderby' => 'meta_value_num' ) );
|
||||
|
||||
$this->assertSame( '_price', $sql['query']->get( 'meta_key' ) );
|
||||
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class PostQueryRouterTest extends TestCase {
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Post_Fields::register_entity_fields();
|
||||
tmdo_test_register_wc_product_group();
|
||||
}
|
||||
|
||||
public static function tearDownAfterClass(): void {
|
||||
@@ -78,7 +79,7 @@ class PostQueryRouterTest extends TestCase {
|
||||
$query = $this->make_query( array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||
),
|
||||
) );
|
||||
$original = $query->get( 'meta_query' );
|
||||
@@ -100,7 +101,7 @@ class PostQueryRouterTest extends TestCase {
|
||||
$query = $this->make_query( array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||
),
|
||||
) );
|
||||
$original = $query->get( 'meta_query' );
|
||||
@@ -123,7 +124,7 @@ class PostQueryRouterTest extends TestCase {
|
||||
$query = $this->make_query( array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||
),
|
||||
) );
|
||||
|
||||
@@ -149,7 +150,7 @@ class PostQueryRouterTest extends TestCase {
|
||||
$query = $this->make_query( array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
|
||||
),
|
||||
) );
|
||||
@@ -177,7 +178,7 @@ class PostQueryRouterTest extends TestCase {
|
||||
$query = $this->make_query( array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||
),
|
||||
) );
|
||||
$original = $query->get( 'meta_query' );
|
||||
@@ -202,7 +203,7 @@ class PostQueryRouterTest extends TestCase {
|
||||
$query = $this->make_query( array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_price', 'value' => '50' ),
|
||||
array( 'key' => '_price', 'value' => '50', 'type' => 'NUMERIC' ),
|
||||
),
|
||||
'wpdo_post_clauses' => array(),
|
||||
) );
|
||||
@@ -214,6 +215,34 @@ class PostQueryRouterTest extends TestCase {
|
||||
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
|
||||
}
|
||||
|
||||
public function test_posts_join_joins_every_routed_group(): void {
|
||||
$this->set_post_mode( 'aeav_only' );
|
||||
|
||||
$router = new WPDO_Post_Query_Router();
|
||||
$query = $this->make_query( array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||
array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ),
|
||||
),
|
||||
) );
|
||||
|
||||
$router->pre_get_posts( $query );
|
||||
$join = $router->posts_join( '', $query );
|
||||
$where = $router->posts_where( '', $query );
|
||||
|
||||
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
|
||||
$this->assertStringContainsString( 'wpdo_post_wp_core', $join );
|
||||
$this->assertStringContainsString( '`wpdoflat_product_wp_core`.`_thumbnail_id` IS NOT NULL', $where );
|
||||
}
|
||||
|
||||
public function test_no_group_by_is_added_for_one_row_per_post_joins(): void {
|
||||
unset( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] );
|
||||
( new WPDO_Post_Query_Router() )->register_hooks();
|
||||
|
||||
$this->assertEmpty( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] ?? array() );
|
||||
}
|
||||
|
||||
public function test_posts_where_appends_condition_for_routed_clause(): void {
|
||||
$this->set_post_mode( 'aeav_only' );
|
||||
|
||||
@@ -221,7 +250,7 @@ class PostQueryRouterTest extends TestCase {
|
||||
$query = $this->make_query( array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_price', 'value' => '99', 'compare' => '=' ),
|
||||
array( 'key' => '_price', 'value' => '99', 'compare' => '=', 'type' => 'NUMERIC' ),
|
||||
),
|
||||
) );
|
||||
|
||||
@@ -242,4 +271,145 @@ class PostQueryRouterTest extends TestCase {
|
||||
|
||||
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
|
||||
}
|
||||
|
||||
// ── all-or-nothing: native unless every registered clause is routable ────
|
||||
|
||||
/**
|
||||
* Run pre_get_posts in aeav_only and assert the query was left to WordPress.
|
||||
*
|
||||
* @param array<string, mixed> $vars Query vars.
|
||||
*/
|
||||
private function assert_not_routed( array $vars, string $why ): void {
|
||||
$this->set_post_mode( 'aeav_only' );
|
||||
$query = $this->make_query( $vars );
|
||||
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||
|
||||
$this->assertSame( $vars['meta_query'], $query->get( 'meta_query' ), $why . ': meta_query untouched.' );
|
||||
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $why . ': nothing routed.' );
|
||||
}
|
||||
|
||||
public function test_or_relation_is_not_routed(): void {
|
||||
$this->assert_not_routed(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
'relation' => 'OR',
|
||||
array( 'key' => '_stock', 'value' => '1' ),
|
||||
array( 'key' => '_sku', 'value' => 'x' ),
|
||||
),
|
||||
),
|
||||
'OR'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_several_post_types_are_not_routed(): void {
|
||||
$this->assert_not_routed(
|
||||
array(
|
||||
'post_type' => array( 'product', 'post' ),
|
||||
'meta_query' => array( array( 'key' => '_price', 'value' => '5', 'compare' => '>=', 'type' => 'NUMERIC' ) ),
|
||||
),
|
||||
'several post types'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_untyped_range_on_a_numeric_column_is_not_routed(): void {
|
||||
$this->assert_not_routed(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array( array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ) ),
|
||||
),
|
||||
'CHAR > on DECIMAL'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_untyped_equality_on_a_decimal_column_is_not_routed(): void {
|
||||
$this->assert_not_routed(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array( array( 'key' => '_price', 'value' => '10' ) ),
|
||||
),
|
||||
'CHAR = on DECIMAL (native "10.00" != "10")'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_numeric_type_on_a_text_column_is_not_routed(): void {
|
||||
$this->assert_not_routed(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array( array( 'key' => '_sku', 'value' => '5', 'compare' => '>', 'type' => 'NUMERIC' ) ),
|
||||
),
|
||||
'NUMERIC on VARCHAR'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_unknown_compare_is_not_routed(): void {
|
||||
$this->assert_not_routed(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array( array( 'key' => '_sku', 'value' => '^a', 'compare' => 'REGEXP' ) ),
|
||||
),
|
||||
'REGEXP'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_one_unroutable_registered_clause_keeps_the_whole_query_native(): void {
|
||||
$this->assert_not_routed(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||
array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ),
|
||||
),
|
||||
),
|
||||
'one unroutable clause'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_nested_group_with_a_registered_key_is_not_routed(): void {
|
||||
$this->assert_not_routed(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||
array(
|
||||
'relation' => 'OR',
|
||||
array( 'key' => '_price', 'compare' => 'NOT EXISTS' ),
|
||||
array( 'key' => '_price', 'compare' => 'EXISTS' ),
|
||||
),
|
||||
),
|
||||
),
|
||||
'nested group'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_untyped_integer_equality_on_an_integer_column_is_routed(): void {
|
||||
$this->set_post_mode( 'aeav_only' );
|
||||
$query = $this->make_query(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array(
|
||||
array( 'key' => '_stock', 'value' => '3' ),
|
||||
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||
|
||||
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
||||
$this->assertCount( 1, $query->get( 'meta_query' ), 'unregistered AND clause stays native' );
|
||||
}
|
||||
|
||||
public function test_like_matches_an_escaped_substring(): void {
|
||||
$this->set_post_mode( 'aeav_only' );
|
||||
$router = new WPDO_Post_Query_Router();
|
||||
$query = $this->make_query(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array( array( 'key' => '_sku', 'value' => 'B1_1%', 'compare' => 'LIKE' ) ),
|
||||
)
|
||||
);
|
||||
$router->pre_get_posts( $query );
|
||||
|
||||
$this->assertStringContainsString( "LIKE '%B1\\\\_1\\\\%%'", $router->posts_where( '', $query ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Hook Bus routes post meta to the group of the post's own type.
|
||||
*
|
||||
* Two groups share `shared_key`; the listing group is scoped to hp_listing,
|
||||
* the vendor group to hp_vendor. Before scoping, both posts' values landed
|
||||
* in the vendor table (last registered wins).
|
||||
*/
|
||||
class PostScopedRoutingTest extends TestCase {
|
||||
|
||||
private const LISTING_TABLE = 'wp_itest_wpdo_post_scope_listing';
|
||||
private const VENDOR_TABLE = 'wp_itest_wpdo_post_scope_vendor';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
global $wpdb;
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-type-caster.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-mode-manager.php',
|
||||
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||
'includes/adapters/class-tmdo-adapter-post.php',
|
||||
'includes/engine/class-tmdo-hook-bus.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
foreach ( array( self::LISTING_TABLE, self::VENDOR_TABLE ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
$wpdb->query(
|
||||
"CREATE TABLE `{$table}` (
|
||||
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
|
||||
`post_id` BIGINT(20) NOT NULL,
|
||||
`shared_key` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_entity` (`post_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function tearDownAfterClass(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( self::LISTING_TABLE, self::VENDOR_TABLE ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
WPDO_Entity_Registry::init();
|
||||
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, null );
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( self::LISTING_TABLE, self::VENDOR_TABLE ) as $table ) {
|
||||
$wpdb->query( "TRUNCATE TABLE `{$table}`" );
|
||||
}
|
||||
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '1' );
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
$GLOBALS['_wp_post_types'] = array( 101 => 'hp_listing', 202 => 'hp_vendor', 303 => 'post' );
|
||||
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
||||
|
||||
$tec = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||
$tec->setAccessible( true );
|
||||
$tec->setValue( null, array( self::LISTING_TABLE => true, self::VENDOR_TABLE => true ) );
|
||||
$l1 = new ReflectionProperty( WPDO_Cache_Orchestrator::class, 'l1_cache' );
|
||||
$l1->setAccessible( true );
|
||||
$l1->setValue( null, array() );
|
||||
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$mode->setAccessible( true );
|
||||
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'scope_listing', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'scope_vendor', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) );
|
||||
}
|
||||
|
||||
private function value_in( string $table, int $post_id ): ?string {
|
||||
global $wpdb;
|
||||
return $wpdb->get_var( $wpdb->prepare( "SELECT shared_key FROM `{$table}` WHERE post_id = %d", $post_id ) );
|
||||
}
|
||||
|
||||
public function test_each_post_type_writes_to_its_own_group_table(): void {
|
||||
WPDO_Hook_Bus::intercept_update( null, 101, 'shared_key', 'listing value', '' );
|
||||
WPDO_Hook_Bus::intercept_update( null, 202, 'shared_key', 'vendor value', '' );
|
||||
|
||||
$this->assertSame( 'listing value', $this->value_in( self::LISTING_TABLE, 101 ) );
|
||||
$this->assertNull( $this->value_in( self::VENDOR_TABLE, 101 ) );
|
||||
$this->assertSame( 'vendor value', $this->value_in( self::VENDOR_TABLE, 202 ) );
|
||||
}
|
||||
|
||||
public function test_reads_come_from_the_post_types_group_table(): void {
|
||||
global $wpdb;
|
||||
$wpdb->insert( self::LISTING_TABLE, array( 'post_id' => 101, 'shared_key' => 'from listing table' ) );
|
||||
$wpdb->insert( self::VENDOR_TABLE, array( 'post_id' => 101, 'shared_key' => 'stray copy' ) );
|
||||
|
||||
$this->assertSame( array( 'from listing table' ), WPDO_Hook_Bus::intercept_get( null, 101, 'shared_key', true, 'post' ) );
|
||||
}
|
||||
|
||||
public function test_other_post_types_fall_through_to_native_meta(): void {
|
||||
$this->assertNull( WPDO_Hook_Bus::intercept_update( null, 303, 'shared_key', 'x', '' ) );
|
||||
$this->assertNull( WPDO_Hook_Bus::intercept_get( null, 303, 'shared_key', true, 'post' ) );
|
||||
}
|
||||
|
||||
public function test_without_the_option_routing_stays_legacy(): void {
|
||||
$GLOBALS['_wp_options'][ WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ] = '0';
|
||||
|
||||
WPDO_Hook_Bus::intercept_update( null, 101, 'shared_key', 'legacy', '' );
|
||||
|
||||
$this->assertSame( 'legacy', $this->value_in( self::VENDOR_TABLE, 101 ) );
|
||||
$this->assertNull( $this->value_in( self::LISTING_TABLE, 101 ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* post_status / post_date copies on post entity tables (O-1 schema).
|
||||
*
|
||||
* Listing 1 (publish) has rows in both groups, draft 2 only in ps_a.
|
||||
*/
|
||||
class PostStatusSyncTest extends TestCase {
|
||||
|
||||
private const A = 'wp_itest_wpdo_post_ps_a';
|
||||
private const B = 'wp_itest_wpdo_post_ps_b';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-type-caster.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-mode-manager.php',
|
||||
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||
'includes/adapters/class-tmdo-adapter-post.php',
|
||||
'includes/engine/class-tmdo-hook-bus.php',
|
||||
'includes/engine/class-tmdo-post-status-sync.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci" );
|
||||
foreach ( array( self::A, self::B ) as $table ) {
|
||||
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, k VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" );
|
||||
}
|
||||
$wpdb->query( "INSERT INTO `{$wpdb->posts}` VALUES (1, 'hp_listing', 'publish', '2026-01-01 10:00:00'), (2, 'hp_listing', 'draft', '2026-02-01 10:00:00'), (3, 'hp_listing', 'pending', '2026-03-01 10:00:00')" );
|
||||
$wpdb->query( 'INSERT INTO `' . self::A . "` (post_id, k) VALUES (1, 'a1'), (2, 'a2')" );
|
||||
$wpdb->query( 'INSERT INTO `' . self::B . "` (post_id, k) VALUES (1, 'b1')" );
|
||||
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
$GLOBALS['_wp_posts'] = array();
|
||||
$GLOBALS['_wp_current_filter'] = '';
|
||||
|
||||
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, array() );
|
||||
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'ps_a', array( array( 'key' => 'k', 'type' => 'text' ) ) );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'ps_b', array( array( 'key' => 'k2', 'type' => 'text' ) ) );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, null );
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( $wpdb->posts, self::A, self::B ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<int, array{0:?string, 1:?string}> post_id → [status, date] */
|
||||
private function copies( string $table ): array {
|
||||
global $wpdb;
|
||||
$out = array();
|
||||
foreach ( (array) $wpdb->get_results( "SELECT post_id, post_status, post_date FROM `{$table}` ORDER BY post_id", ARRAY_A ) as $row ) {
|
||||
$out[ (int) $row['post_id'] ] = array( $row['post_status'], $row['post_date'] );
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function has_index( string $table ): bool {
|
||||
global $wpdb;
|
||||
return in_array( 'idx_post_status_date', array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ), true );
|
||||
}
|
||||
|
||||
public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void {
|
||||
$report = TMDO_Post_Status_Sync::run( false, 1 );
|
||||
|
||||
$this->assertSame(
|
||||
array(
|
||||
1 => array( 'publish', '2026-01-01 10:00:00' ),
|
||||
2 => array( 'draft', '2026-02-01 10:00:00' ),
|
||||
),
|
||||
$this->copies( self::A )
|
||||
);
|
||||
$this->assertSame( array( 1 => array( 'publish', '2026-01-01 10:00:00' ) ), $this->copies( self::B ) );
|
||||
$this->assertTrue( $this->has_index( self::A ) );
|
||||
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
||||
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_b' ) );
|
||||
$this->assertSame( 0, TMDO_Post_Status_Sync::mismatches( 'ps_a' ) );
|
||||
$this->assertSame( 2, $report['ps_a']['mismatched'] );
|
||||
}
|
||||
|
||||
public function test_copies_use_the_posts_collation_so_they_compare_with_posts(): void {
|
||||
global $wpdb;
|
||||
TMDO_Post_Status_Sync::run();
|
||||
|
||||
$column = $wpdb->get_row( 'SHOW FULL COLUMNS FROM `' . self::A . "` LIKE 'post_status'", ARRAY_A );
|
||||
$this->assertSame( 'utf8mb4_unicode_520_ci', $column['Collation'] );
|
||||
$wpdb->query( 'UPDATE `' . self::A . "` SET post_status = 'stale' WHERE post_id = 2" );
|
||||
$this->assertSame( 1, TMDO_Post_Status_Sync::mismatches( 'ps_a' ) );
|
||||
}
|
||||
|
||||
public function test_dry_run_only_counts(): void {
|
||||
global $wpdb;
|
||||
$report = TMDO_Post_Status_Sync::run( true );
|
||||
|
||||
$this->assertSame( 2, $report['ps_a']['mismatched'] );
|
||||
$this->assertSame( 1, $report['ps_b']['mismatched'] );
|
||||
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
|
||||
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
||||
}
|
||||
|
||||
public function test_status_transition_updates_the_post_in_every_group_table(): void {
|
||||
TMDO_Post_Status_Sync::run();
|
||||
|
||||
TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) );
|
||||
|
||||
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::A )[1] );
|
||||
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::B )[1] );
|
||||
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2], 'other posts untouched' );
|
||||
}
|
||||
|
||||
public function test_a_group_whose_table_was_dropped_is_forgotten(): void {
|
||||
global $wpdb;
|
||||
TMDO_Post_Status_Sync::run();
|
||||
$wpdb->query( 'DROP TABLE `' . self::B . '`' );
|
||||
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, array() );
|
||||
$wpdb->last_error = '';
|
||||
|
||||
TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) );
|
||||
|
||||
$this->assertSame( '', (string) $wpdb->last_error, 'no UPDATE of a missing table' );
|
||||
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_b' ), 'forgotten' );
|
||||
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
||||
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::A )[1] );
|
||||
}
|
||||
|
||||
public function test_transition_leaves_tables_without_the_columns_alone(): void {
|
||||
global $wpdb;
|
||||
$wpdb->last_error = '';
|
||||
|
||||
TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) );
|
||||
|
||||
$this->assertSame( '', (string) $wpdb->last_error );
|
||||
}
|
||||
|
||||
public function test_new_empty_table_gets_the_columns_at_once_but_a_populated_one_waits_for_the_cli(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'TRUNCATE TABLE `' . self::B . '`' );
|
||||
|
||||
TMDO_Post_Status_Sync::on_schema_updated( 'post', 'ps_b', self::B );
|
||||
TMDO_Post_Status_Sync::on_schema_updated( 'post', 'ps_a', self::A );
|
||||
|
||||
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_b' ) );
|
||||
$this->assertTrue( $this->has_index( self::B ) );
|
||||
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
||||
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
|
||||
}
|
||||
|
||||
public function test_flat_write_that_creates_a_row_fills_the_copies(): void {
|
||||
TMDO_Post_Status_Sync::run();
|
||||
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$mode->setAccessible( true );
|
||||
// aeav_only: the pre-filter itself writes flat (dual_write syncs from native after the write).
|
||||
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' );
|
||||
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
||||
|
||||
WPDO_Hook_Bus::intercept_update( null, 3, 'k', 'a3', '' );
|
||||
WPDO_Hook_Bus::intercept_update( null, 1, 'k', 'a1b', '' );
|
||||
|
||||
$this->assertSame( array( 'pending', '2026-03-01 10:00:00' ), $this->copies( self::A )[3] );
|
||||
$this->assertSame( array( 'publish', '2026-01-01 10:00:00' ), $this->copies( self::A )[1], 'existing row keeps its synced copies' );
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ class RestApiIntegrationTest extends TestCase {
|
||||
'zone' => 'hot',
|
||||
'column' => 'rp_price',
|
||||
'type' => 'decimal',
|
||||
'show_in_rest' => true,
|
||||
] );
|
||||
$registry->register( 'integration_rest', [
|
||||
'post_type' => 'restapi',
|
||||
@@ -40,11 +41,21 @@ class RestApiIntegrationTest extends TestCase {
|
||||
'zone' => 'hot',
|
||||
'column' => 'rp_featured',
|
||||
'type' => 'tinyint',
|
||||
'show_in_rest' => true,
|
||||
] );
|
||||
$registry->register( 'integration_rest', [
|
||||
'post_type' => 'restapi',
|
||||
'meta_key' => 'rp_description',
|
||||
'zone' => 'cold',
|
||||
'show_in_rest' => true,
|
||||
] );
|
||||
// Registered but not public (1.1.1: fields are private by default).
|
||||
$registry->register( 'integration_rest', [
|
||||
'post_type' => 'restapi',
|
||||
'meta_key' => 'rp_cost',
|
||||
'zone' => 'hot',
|
||||
'column' => 'rp_cost',
|
||||
'type' => 'decimal',
|
||||
] );
|
||||
|
||||
// Create hot table.
|
||||
@@ -53,6 +64,7 @@ class RestApiIntegrationTest extends TestCase {
|
||||
post_id BIGINT UNSIGNED NOT NULL,
|
||||
rp_price DECIMAL(10,2) DEFAULT NULL,
|
||||
rp_featured TINYINT(1) DEFAULT NULL,
|
||||
rp_cost DECIMAL(10,2) DEFAULT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (post_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
@@ -72,12 +84,30 @@ 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;
|
||||
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" );
|
||||
$status = $statuses[ $i ] ?? 'publish';
|
||||
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES ($i, 'restapi', '$status')" );
|
||||
$cost = 1000 - $price;
|
||||
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured, rp_cost) VALUES ($i, $price, $featured, $cost)" );
|
||||
}
|
||||
$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 +117,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 +128,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.
|
||||
@@ -175,6 +207,33 @@ class RestApiIntegrationTest extends TestCase {
|
||||
$this->assertTrue( true );
|
||||
}
|
||||
|
||||
public function test_listings_never_return_a_private_column(): void {
|
||||
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||
$req->set_param( 'post_type', 'restapi' );
|
||||
|
||||
foreach ( self::$api->get_listings( $req )->get_data() as $item ) {
|
||||
$this->assertArrayNotHasKey( 'rp_cost', $item );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_private_column_cannot_be_used_as_a_filter(): void {
|
||||
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||
$req->set_param( 'post_type', 'restapi' );
|
||||
$req->set_param( 'rp_cost_min', 800 );
|
||||
|
||||
$this->assertSame( '5', self::$api->get_listings( $req )->get_headers()['X-WP-Total'], 'filter on a private column is ignored' );
|
||||
}
|
||||
|
||||
public function test_private_column_cannot_be_used_for_ordering(): void {
|
||||
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||
$req->set_param( 'post_type', 'restapi' );
|
||||
$req->set_param( 'orderby', 'rp_cost' );
|
||||
$req->set_param( 'order', 'ASC' );
|
||||
|
||||
// rp_cost descends as post_id rises; ignoring it falls back to post_id ASC.
|
||||
$this->assertSame( array( 1, 2, 3, 4, 5 ), array_column( self::$api->get_listings( $req )->get_data(), 'id' ) );
|
||||
}
|
||||
|
||||
public function test_listings_filter_price_min(): void {
|
||||
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||
$req->set_param( 'post_type', 'restapi' );
|
||||
@@ -232,6 +291,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 {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* An existing flat table whose definition changed is upgraded from WP-CLI /
|
||||
* WP-Cron only (2.3 PR-107); web requests record the group as deferred.
|
||||
*/
|
||||
class SchemaUpgradeDeferralTest extends TestCase {
|
||||
|
||||
private const TABLE = 'wp_itest_wpdo_user_sd_core';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/engine/class-tmdo-type-caster.php',
|
||||
'includes/engine/class-tmdo-schema-manager.php',
|
||||
'includes/engine/class-tmdo-entity-registry.php',
|
||||
'includes/engine/class-tmdo-mode-manager.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
WPDO_Installer::install_v2_tables();
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, sd_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
|
||||
$wpdb->insert( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core', 'schema_hash' => 'old', 'field_definitions' => '[]' ) );
|
||||
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
$GLOBALS['_wp_scheduled_single'] = array();
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); // A web request.
|
||||
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, array() );
|
||||
self::set_user_mode( 'dual_write' );
|
||||
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||
WPDO_Entity_Registry::register_group( 'user', 'sd_core', array( array( 'key' => 'sd_a', 'type' => 'text' ), array( 'key' => 'sd_b', 'type' => 'integer' ) ) );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
|
||||
WPDO_Entity_Registry::init();
|
||||
self::set_user_mode( null );
|
||||
unset( $GLOBALS['_wp_scheduled_single'] );
|
||||
}
|
||||
|
||||
private static function set_user_mode( ?string $mode ): void {
|
||||
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||
$ref->setAccessible( true );
|
||||
$ref->setValue( null, null === $mode ? null : array( 'post' => 'disabled', 'user' => $mode, 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||
}
|
||||
|
||||
private function upgrade(): bool {
|
||||
return TMDO_Schema_Manager::create_or_upgrade_table( 'user', 'sd_core', WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
|
||||
}
|
||||
|
||||
public function test_a_web_request_leaves_the_upgrade_to_wp_cron_and_the_group_native(): void {
|
||||
global $wpdb;
|
||||
|
||||
$this->assertFalse( $this->upgrade() );
|
||||
|
||||
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
|
||||
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
|
||||
$this->assertSame( array( 'id', 'user_id', 'sd_a' ), $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::TABLE . '`' ), 'no ALTER' );
|
||||
$this->assertSame( 'old', TMDO_Schema_Manager::get_stored_schema_hash( 'user', 'sd_core' ) );
|
||||
}
|
||||
|
||||
public function test_only_existing_tables_outside_aeav_only_and_outside_cli_or_cron_wait(): void {
|
||||
$this->assertTrue( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
|
||||
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', 'wp_itest_wpdo_user_sd_missing' ), 'a new table is created at once' );
|
||||
|
||||
self::set_user_mode( 'aeav_only' );
|
||||
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ), 'aeav_only: the flat table is the only copy' );
|
||||
|
||||
self::set_user_mode( 'dual_write' );
|
||||
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // WP-CLI / WP-Cron.
|
||||
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* An existing table is altered by one process at a time: anonymous
|
||||
* /wp-cron.php requests pass ddl_allowed() too (security review H1).
|
||||
*/
|
||||
public function test_an_upgrade_is_skipped_while_another_process_holds_the_schema_lock(): void {
|
||||
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true );
|
||||
$other = new mysqli(
|
||||
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
|
||||
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
|
||||
(string) getenv( 'TMDO_TEST_DB_PASS' ),
|
||||
getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test'
|
||||
);
|
||||
$name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() );
|
||||
$other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" );
|
||||
try {
|
||||
$this->assertFalse( $this->upgrade(), 'returns before dbDelta (unavailable here)' );
|
||||
} finally {
|
||||
$other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" );
|
||||
$other->close();
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
|
||||
}
|
||||
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* After the upgrade the group stays native until the drift fix has
|
||||
* rebuilt the rows written meanwhile (security review M1).
|
||||
*/
|
||||
public function test_a_drift_fix_releases_only_upgraded_groups_of_its_entity(): void {
|
||||
global $wpdb;
|
||||
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
|
||||
update_option(
|
||||
TMDO_Schema_Manager::DEFERRED_OPTION,
|
||||
array(
|
||||
'user/sd_core' => 1,
|
||||
'user/sd_later' => 1, // Not upgraded yet (no stored definition).
|
||||
'post/sd_core' => 1, // Another entity.
|
||||
)
|
||||
);
|
||||
|
||||
TMDO_Schema_Manager::resynced( 'user' );
|
||||
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'stored hash still old: not upgraded' );
|
||||
|
||||
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
|
||||
TMDO_Schema_Manager::resynced( 'user' );
|
||||
|
||||
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
|
||||
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_later' ) );
|
||||
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'post', 'sd_core' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition went back to the stored one (or another process upgraded
|
||||
* the table) while the group was deferred (correctness review F2).
|
||||
*/
|
||||
public function test_a_deferred_group_whose_definition_matches_again_is_resynced_then_released(): void {
|
||||
global $wpdb;
|
||||
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
|
||||
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
|
||||
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
|
||||
|
||||
$this->assertTrue( $this->upgrade() );
|
||||
|
||||
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'dual_write: native until the drift fix' );
|
||||
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
|
||||
|
||||
self::set_user_mode( 'disabled' );
|
||||
$this->upgrade();
|
||||
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'no flat writes: nothing to rebuild' );
|
||||
}
|
||||
|
||||
/**
|
||||
* The cron event upgrades deferred groups itself: with ALTERNATE_WP_CRON
|
||||
* it runs inside a visitor's request, after init (correctness review F3).
|
||||
*/
|
||||
public function test_the_cron_event_handles_deferred_groups(): void {
|
||||
global $wpdb;
|
||||
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
|
||||
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
|
||||
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
|
||||
|
||||
TMDO_Schema_Manager::ensure_pending_indexes();
|
||||
|
||||
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user