Compare commits
69 Commits
hotfix/1.1.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c15266034 | |||
| c13691bcea | |||
| 04ae4c0d0f | |||
| 6f117f4e9b | |||
| c9d5a08a42 | |||
| cd5da4143e | |||
| 15096f064f | |||
| 90877b81e0 | |||
| 6687d54bef | |||
| 86ac84e07c | |||
| b6200c581c | |||
| 81324903a6 | |||
| 3e96357459 | |||
| 80343ec282 | |||
| 33addd1e49 | |||
| bed3f906da | |||
| 7ad65eab95 | |||
| 4b34851316 | |||
| b30baeddf0 | |||
| 13c4a1bcb9 | |||
| 692b23aed9 | |||
| 8ee3dce0fe | |||
| 2aa9058ee9 | |||
| 66db9e4c56 | |||
| 2ed48c3ba3 | |||
| eac0955b76 | |||
| b2725b219d | |||
| 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 |
@@ -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.1.1
|
||||
* Version: 1.4.3
|
||||
* 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.1.1' );
|
||||
define( 'TMDO_VERSION', '1.4.3' );
|
||||
define( 'TMDO_DB_VERSION', '2.1.0' );
|
||||
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
||||
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
||||
@@ -268,6 +268,7 @@ add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'b
|
||||
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';
|
||||
|
||||
+267
@@ -7,6 +7,265 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
## [1.4.2] — 2026-09-28 — 手冊已知問題修正(hardening 2.4.2)
|
||||
|
||||
**性質**:修正。無 schema 版本變更;archive 表新增索引(見「升級後」)。來源:`2meet-do-manual.md`
|
||||
附錄 F(F-2、F-4〜F-8、F-11)。
|
||||
|
||||
### Fixed
|
||||
|
||||
- **設定頁會說明模式為何沒有變更**:完整表單與 AJAX 區塊儲存都經 `TMDO_Mode_Manager::set()`,
|
||||
過去忽略它回傳的 `WP_Error`:不安全的跳階或 Promotion Guard 拒絕時,頁面仍顯示「已儲存」、
|
||||
模式卻沒變。現在完整表單在重新導向後顯示拒絕原因(每位使用者一次),AJAX 儲存以錯誤訊息回傳。(PR-135,F-2)
|
||||
- **archive 不再重複歸檔**:`wpdo_archive_sweep` 略過已在 archive 表的 meta(依 `original_meta_id`),
|
||||
過去同一筆 meta 每次 sweep 都再寫一列(dev20:300 列只有 150 筆不同)。archive 表新增
|
||||
`idx_original_meta` 索引;`wp tmdo install` 會補上索引並刪除重複列(保留最早一列)。(PR-136,F-6)
|
||||
- **entity 健康面板看得到執行中的 backfill**:批次以 `(entity, group)` 參數排程,面板過去只查
|
||||
`(entity)`,永遠看不到、一直提示可開始。(PR-137,F-11)
|
||||
- **停用與解除安裝清掉所有排程**:改用 `wp_unschedule_hook()`,連帶參數排程的事件
|
||||
(`tmdo_drift_resync`、backfill 批次)也清除;修正錯誤的 `wpdo_user_stress_test_batch` 名稱,
|
||||
補上 `wpdo_migration_tick`、`wpdo_entity_backfill_batch`、`wpdo_auto_promote_check` 與各壓測批次。
|
||||
解除安裝另外刪除 `tmdo_*` option 及 `tmdo_*` transient。(PR-138,F-4、F-5)
|
||||
- 解除安裝被拒絕時的提示改為指向 `wp tmdo bridge-restore-native`(過去說沒有寫回工具);post
|
||||
壓測工具不再使用已退役的 `wc_product` 表。(PR-139,F-7、F-8)
|
||||
|
||||
### 升級後
|
||||
|
||||
- 每個站台執行一次 `wp tmdo install`:為 archive 表加上 `idx_original_meta` 並刪除重複的 archive
|
||||
列(刪除重複只在 CLI 執行,web 啟用外掛時不做)。表很大時請在離峰時段執行。
|
||||
- **未執行前 archive sweep 會暫停**(沒有索引時 NOT EXISTS 會對每筆候選掃整張 archive 表)。
|
||||
sweep 只複製、不刪 postmeta,暫停不會遺失資料。
|
||||
- 解除安裝的 option 清理保留 LatePoint AddOn 的 `tmdo_latepoint_db_version`;設定頁的 AJAX 錯誤訊息
|
||||
不再 2.5 秒後自動消失。(PR-141,驗收修正)
|
||||
|
||||
### 已知、未修改
|
||||
|
||||
- 已歸檔的 meta 以 `meta_id` 判斷:文章還原、修改後再次丟到回收桶,archive 保留的是第一次的值。
|
||||
- post 壓測工具預設仍是 `product`,但 product 已沒有 flat 表,量測會回報 `flat_table_missing`。
|
||||
|
||||
---
|
||||
|
||||
## [1.4.1] — 2026-09-28 — 2.4 驗收修正
|
||||
|
||||
**性質**:修正。無 schema 變更。來源:2.4 全面驗收的 security review 與 correctness review。
|
||||
|
||||
### Fixed
|
||||
|
||||
- **meta_key 的排序不再被清掉**:查詢帶 `meta_key`,且 orderby 在多個排序(陣列或
|
||||
`'meta_value date'`)中仍有 `meta_value`、`meta_value_num` 或該 key 名稱時,1.4.0 會把
|
||||
meta_key 當 EXISTS 路由並清空,WordPress 因此丟掉價格排序。現在只在 orderby 完全沒有引用它時
|
||||
才路由;`meta_compare_key` 不是 `=`(例如 LIKE 會比對到其他 key)時維持原生;排序形狀的
|
||||
group 只有在 orderby 沒有引用它、`meta_value(_num)` 或它的 key 時才當成未使用而略過(沒有
|
||||
meta_key 時它是 WordPress 的主要 clause)。(PR-130)
|
||||
- **ORDER BY 只改寫單獨的 `posts.ID`**:flat-first 與 copy route 共用 `ties_on_post_id()`;
|
||||
外掛加上的 `FIELD(wp_posts.ID, …)`、`(wp_posts.ID IN (…))` 等運算式保持原樣。1.4.0 會在
|
||||
運算式內插入方向,SQL 語法錯誤、頁面沒有結果。正則失敗(PCRE 回溯或 JIT 上限)時回傳原
|
||||
SQL,不再變成空字串。(PR-131)
|
||||
- 呼叫端以 `new WP_Query( array( … ) )` 預設的 router 內部 query var(`wpdo_post_clauses`、
|
||||
`wpdo_post_sort`、`wpdo_post_copy_route`、`wpdo_post_count_sql`)一律清除;copy route 的
|
||||
排序方向改用白名單。無法從網址觸發。(PR-132)
|
||||
- 新文章建立的空 flat 列一併寫入 `post_status` / `post_date` 副本(`wp_insert_post` 在
|
||||
`transition_post_status` 之後才執行,原本留下 NULL,會讓整個 group 關閉 copy route);
|
||||
`wp tmdo post-status-sync` 完成後遞增路由計數版本(dry run 不遞增)。(PR-133)
|
||||
|
||||
### 已知、未修改
|
||||
|
||||
- 1.4.0 的 flat-first 排序同值時依 ID 與排序同方向(原生為 ID DESC),集合與總數相同(已接受)。
|
||||
- 若日後有 group 註冊名為 `post_status` / `post_date` 的欄位,遷移引擎寫副本時 `$data` 與
|
||||
`$formats` 會錯位;目前沒有這樣的 group。
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] — 2026-09-28 — 百萬級 listing 的路由排序與日期查詢(hardening 2.4)
|
||||
|
||||
**性質**:效能 + 修正。無 schema 變更;post status/date 副本的索引改為三欄(見「升級後」)。
|
||||
搭配 HivePress AddOn 0.5.0。來源:dev36 第 3 階壓測(130 萬 listing、121 萬 product、6.2 萬 vendor)。
|
||||
|
||||
### Changed
|
||||
|
||||
- **flat-first 排序讀 flat 的 post_id 作 tie-break**:`posts.ID` 的 tie-break(ours 與外掛追加的)
|
||||
改讀相等的 `flat.post_id`,方向與排序一致且只出現一次;group 宣告的索引可以 adapter 的
|
||||
entity id 欄結尾,`(欄, post_id)` 索引可直接依序讀取、不需 filesort(主查詢 850 ms → 2 ms)。
|
||||
同值的順序改為依 ID 與排序同方向(原生為 ID DESC);集合、總數不變,翻頁穩定。(PR-118)
|
||||
- **沒有 value 的 meta 條件以 EXISTS 路由**:clause 沒有 `value` 時(HivePress 的精選 listing
|
||||
查詢)視同 EXISTS;只帶 `meta_key`、沒有 `meta_value` 也不以 meta_value 排序的查詢亦同;
|
||||
沒有被 orderby 引用的排序形狀 group 直接移除。HivePress 每個搜尋頁都會跑的精選 listing
|
||||
查詢(`orderby rand`)改走扁平表:2.26 s → 0.2 s。(PR-122)
|
||||
- **status/date copy route 在 LIMIT 停下**:只有扁平表一個 JOIN 時去掉 WordPress 的
|
||||
`GROUP BY posts.ID`,`posts.ID` tie-break 改讀 `flat.post_id`(各自保留方向、重複合併);副本索引改為
|
||||
`idx_post_status_date_id (post_status, post_date, post_id)`,取代兩欄的 `idx_post_status_date`。
|
||||
dev36 價格區間(130 萬中 23.5 萬筆符合)主查詢 1.7 s → 3 ms,頁面 2.7 s → 0.34 s。(PR-128)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 遷移引擎(backfill、`drift-check --fix`)以 REPLACE 重建 post 列時保留 `post_status` /
|
||||
`post_date` 副本;原本重建後為 NULL,一筆 NULL 就讓整個 group 關閉 copy route。(PR-127)
|
||||
- 遷移引擎重建 post 列後遞增路由計數版本(每次呼叫/每批一次,dry run 不遞增),
|
||||
修正後的 found rows 不再沿用舊的快取計數。(PR-129)
|
||||
|
||||
### Dev
|
||||
|
||||
- live 測試工具(`mut.php`、`qbench.php`)不再清空物件快取(Redis Object Cache 以伺服器端腳本
|
||||
掃描,會卡住共用 Redis 5–20 s)。(PR-123)
|
||||
|
||||
### 升級後
|
||||
|
||||
- 只需替換索引:對 `tmdo_post_status_columns` 列出的每個 group 表執行
|
||||
`ALTER TABLE <表> ADD INDEX idx_post_status_date_id (post_status, post_date, post_id), DROP INDEX idx_post_status_date`
|
||||
(空表瞬間完成,dev36 130 萬列約 5 s)。未替換前 copy route 照常運作,只是維持 1.3.0 的速度。
|
||||
- **不要為了換索引執行 `wp tmdo post-status-sync`**:它除了替換索引,還會替**所有**尚未有副本的
|
||||
post group 加上 `post_status` / `post_date` 欄位並逐列回填(大表 ALTER + 全表 UPDATE;dev36
|
||||
130 萬列約 75 s,上千萬列的站更久)。只有要讓更多 group 啟用 copy route 時才執行,並先在
|
||||
離峰時段以 `--dry-run` 確認列數。
|
||||
- 效能數字(dev36 前台,routed):價格區間 0.34 s、價格排序 0.37 s、時薪排序 0.35 s、
|
||||
評分排序 0.51 s、關鍵字 + 價格 0.59 s(原生 6–43 s)。
|
||||
|
||||
---
|
||||
|
||||
## [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/`)。
|
||||
@@ -342,3 +601,11 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
||||
- PHPCS 套件 + 0/0 達成
|
||||
- 打包腳本 10 終檢驗收
|
||||
- WPDO_ → TMDO_ migration CLI
|
||||
|
||||
## [1.4.3] — 2026-10-02 — 重新打包
|
||||
|
||||
### Changed
|
||||
- 重新打包(無功能變更):與 WP-MVB v8 樞紐站(dev39)一同驗收後發版;程式碼與前一版相同。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -158,6 +158,13 @@ semantics, LIKE as `%esc_like%`. Meta ordering (the HivePress `_sort` NOT 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)
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a transient status message, then fade it out after 2.5s.
|
||||
* Show a status message; success fades out after 2.5s, errors stay.
|
||||
*
|
||||
* @param {HTMLElement} statusEl
|
||||
* @param {string} message
|
||||
@@ -56,6 +56,9 @@
|
||||
statusEl.style.color = isError ? 'var(--color-error, #b03d3d)' : 'var(--color-success, #5a8c5a)';
|
||||
statusEl.classList.add( 'visible' );
|
||||
clearTimeout( statusEl._wpdo_timer );
|
||||
if ( isError ) {
|
||||
return;
|
||||
}
|
||||
statusEl._wpdo_timer = setTimeout( () => {
|
||||
statusEl.classList.remove( 'visible' );
|
||||
}, 2500 );
|
||||
@@ -92,7 +95,7 @@
|
||||
if ( data.success ) {
|
||||
showStatus( statusEl, i18n.saved, false );
|
||||
} else {
|
||||
showStatus( statusEl, i18n.error, true );
|
||||
showStatus( statusEl, ( data.data && data.data.message ) || i18n.error, true );
|
||||
}
|
||||
} )
|
||||
.catch( () => {
|
||||
|
||||
+112
-38
@@ -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;
|
||||
}
|
||||
@@ -394,17 +398,7 @@ class TMDO_Admin {
|
||||
|
||||
// v2.5.4: Entity Bridge settings (v2.11.0: post added).
|
||||
update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false );
|
||||
if ( class_exists( 'TMDO_Mode_Manager' ) ) {
|
||||
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
|
||||
$new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
|
||||
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
|
||||
: '';
|
||||
if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) {
|
||||
TMDO_Mode_Manager::set( $entity_type, $new_mode );
|
||||
}
|
||||
}
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
self::remember_bridge_mode_refusals( self::apply_bridge_modes( self::posted_bridge_modes() ) );
|
||||
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
|
||||
TMDO_Hook_Bus_Bridge::reset_cache();
|
||||
}
|
||||
@@ -556,11 +550,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 +746,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 +777,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';
|
||||
@@ -846,8 +849,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="
|
||||
@@ -1099,7 +1102,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>
|
||||
@@ -1116,7 +1119,11 @@ class TMDO_Admin {
|
||||
<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; ?>
|
||||
@@ -1726,13 +1733,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>
|
||||
@@ -2037,7 +2044,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">
|
||||
@@ -2071,7 +2078,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'] ) ) : ?>
|
||||
@@ -3127,6 +3134,9 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
||||
if ( isset( $_GET['wpdo_msg'] ) && 'settings_saved' === sanitize_key( wp_unslash( (string) $_GET['wpdo_msg'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( '設定已儲存。', '2meet-data-optimizer' ) . '</p></div>';
|
||||
}
|
||||
foreach ( self::pull_bridge_mode_refusals() as $refused ) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__( '模式未變更:', '2meet-data-optimizer' ) . esc_html( $refused ) . '</p></div>';
|
||||
}
|
||||
$enabled = class_exists( 'TMDO_Email_Notifier' ) && TMDO_Email_Notifier::is_enabled();
|
||||
$email = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::recipient() : '';
|
||||
$throttle = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::throttle_hours() : 24;
|
||||
@@ -3726,22 +3736,33 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
||||
}
|
||||
}
|
||||
|
||||
private static function save_section_entity_bridge(): void {
|
||||
/**
|
||||
* Save the Entity Bridge section (Hook Bus switch and bridge modes).
|
||||
*
|
||||
* @return string[] Refused changes, "<entity>:<reason>".
|
||||
*/
|
||||
private static function save_section_entity_bridge(): array {
|
||||
update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false );
|
||||
if ( class_exists( 'TMDO_Mode_Manager' ) ) {
|
||||
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
|
||||
$new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
|
||||
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
|
||||
: '';
|
||||
if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) {
|
||||
TMDO_Mode_Manager::set( $entity_type, $new_mode );
|
||||
}
|
||||
}
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
$refused = self::apply_bridge_modes( self::posted_bridge_modes() );
|
||||
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
|
||||
TMDO_Hook_Bus_Bridge::reset_cache();
|
||||
}
|
||||
return $refused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge modes posted by the settings form, keyed by entity type ('' when absent).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function posted_bridge_modes(): array {
|
||||
$posted = array();
|
||||
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
|
||||
$posted[ $entity_type ] = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
|
||||
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
|
||||
: '';
|
||||
}
|
||||
return $posted;
|
||||
}
|
||||
|
||||
private static function save_section_hp_transient(): void {
|
||||
@@ -3769,6 +3790,56 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing,Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.FunctionComment.WrongStyle
|
||||
|
||||
/**
|
||||
* Apply posted bridge modes through TMDO_Mode_Manager::set(). Empty or
|
||||
* unknown modes are skipped; refused changes (unsafe jump, Promotion Guard)
|
||||
* are returned instead of being dropped silently (2.4.2 PR-135).
|
||||
*
|
||||
* @param array<string, string> $posted Mode per entity type.
|
||||
* @return string[] Refused changes, "<entity>:<reason>".
|
||||
*/
|
||||
public static function apply_bridge_modes( array $posted ): array {
|
||||
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
||||
return array();
|
||||
}
|
||||
$refused = array();
|
||||
foreach ( $posted as $entity_type => $new_mode ) {
|
||||
if ( ! TMDO_Mode_Manager::is_valid_mode( (string) $new_mode ) ) {
|
||||
continue;
|
||||
}
|
||||
$result = TMDO_Mode_Manager::set( (string) $entity_type, (string) $new_mode );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
$refused[] = $entity_type . ':' . $result->get_error_message();
|
||||
}
|
||||
}
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
return $refused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep refused bridge-mode changes for the page the settings form redirects to.
|
||||
*
|
||||
* @param string[] $refused Refused changes.
|
||||
* @return void
|
||||
*/
|
||||
public static function remember_bridge_mode_refusals( array $refused ): void {
|
||||
if ( $refused ) {
|
||||
set_transient( 'wpdo_mode_refused_' . get_current_user_id(), $refused, 5 * MINUTE_IN_SECONDS );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refused bridge-mode changes remembered for this user, once.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function pull_bridge_mode_refusals(): array {
|
||||
$key = 'wpdo_mode_refused_' . get_current_user_id();
|
||||
$refused = get_transient( $key );
|
||||
delete_transient( $key );
|
||||
return is_array( $refused ) ? $refused : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: save a single settings section without a full page reload.
|
||||
*/
|
||||
@@ -3790,7 +3861,10 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
|
||||
if ( ! isset( $handlers[ $section ] ) ) {
|
||||
wp_send_json_error( 'invalid_section', 400 );
|
||||
}
|
||||
self::{$handlers[ $section ]}();
|
||||
$refused = self::{$handlers[ $section ]}();
|
||||
if ( is_array( $refused ) && $refused ) {
|
||||
wp_send_json_error( array( 'message' => __( '部分模式未變更:', '2meet-data-optimizer' ) . implode( ';', $refused ) ) );
|
||||
}
|
||||
wp_send_json_success( array( 'message' => __( '設定已儲存。', '2meet-data-optimizer' ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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.' );
|
||||
}
|
||||
|
||||
+55
-15
@@ -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).
|
||||
@@ -439,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]
|
||||
@@ -447,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.
|
||||
@@ -470,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.' );
|
||||
}
|
||||
@@ -503,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) ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -442,9 +442,9 @@ class TMDO_CLI_Term_Comment {
|
||||
|
||||
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 ) );
|
||||
@@ -504,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) ──────────────────────────
|
||||
|
||||
+142
-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,131 @@ 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.
|
||||
@@ -918,6 +1050,10 @@ 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' ) );
|
||||
|
||||
+91
-23
@@ -94,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.
|
||||
*
|
||||
@@ -195,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' ) ) {
|
||||
|
||||
@@ -77,6 +77,26 @@
|
||||
|
||||
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 分鐘。
|
||||
|
||||
## 後果
|
||||
|
||||
**好處**
|
||||
@@ -89,7 +109,7 @@ HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `opti
|
||||
|
||||
- **寫入開銷**: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` 與重新啟用時的增量 backfill(E01 / E02)處理;在那之前,大量匯入後要重新 backfill。
|
||||
- **漂移**:繞過 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。
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ class TMDO_Cache_Layer {
|
||||
|
||||
// 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 );
|
||||
@@ -136,18 +136,18 @@ class TMDO_Cache_Layer {
|
||||
/**
|
||||
* 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 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -261,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' ) );
|
||||
@@ -296,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' ) );
|
||||
|
||||
|
||||
@@ -217,6 +217,16 @@ class TMDO_Installer {
|
||||
// Idempotent — only creates tables that don't already exist.
|
||||
self::install_v2_tables();
|
||||
|
||||
// 1.4.2: archive sweeps before this release copied trashed meta daily.
|
||||
// One unbounded DELETE: CLI only, never in a web activation.
|
||||
if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( 'TMDO_Zone_Archive' ) ) {
|
||||
global $wpdb;
|
||||
$archive = TMDO_Zone_Archive::table();
|
||||
if ( $archive === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $archive ) ) ) ) {
|
||||
TMDO_Zone_Archive::remove_duplicates();
|
||||
}
|
||||
}
|
||||
|
||||
update_option( 'wpdo_db_version', self::SCHEMA_VERSION );
|
||||
|
||||
// Register wpdo_features with autoload=no so it doesn't inflate every page load.
|
||||
@@ -771,29 +781,46 @@ class TMDO_Installer {
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every cron hook the plugin schedules. Cleared with wp_unschedule_hook(),
|
||||
* which also removes events scheduled with args (tmdo_drift_resync( 'post' ),
|
||||
* backfill batches) that wp_clear_scheduled_hook( $hook ) leaves behind
|
||||
* (2.4.2 PR-138).
|
||||
*/
|
||||
private const CRON_HOOKS = array(
|
||||
'wpdo_warm_cleanup',
|
||||
'wpdo_archive_sweep',
|
||||
'wpdo_errors_gc',
|
||||
'wpdo_flush_views',
|
||||
'wpdo_remove_uae_plugin_dir',
|
||||
'wpdo_daily_health_check',
|
||||
'wpdo_snapshot_prune_daily',
|
||||
'wpdo_fsm_automator_run',
|
||||
'wpdo_collect_site_metrics',
|
||||
'wpdo_health_snapshot_monthly',
|
||||
'wpdo_post_shadow_verify',
|
||||
'wpdo_term_comment_shadow_verify',
|
||||
'tmdo_drift_resync',
|
||||
'tmdo_ensure_group_indexes',
|
||||
'wpdo_entity_backfill_batch',
|
||||
'wpdo_migration_tick',
|
||||
'wpdo_auto_promote_check',
|
||||
'wpdo_post_stress_test_batch',
|
||||
'wpdo_stress_test_batch',
|
||||
'wpdo_term_stress_test_batch',
|
||||
'wpdo_comment_stress_test_batch',
|
||||
);
|
||||
|
||||
/**
|
||||
* Called on plugin deactivation. Non-destructive.
|
||||
*/
|
||||
public static function deactivate(): void {
|
||||
wp_clear_scheduled_hook( 'wpdo_warm_cleanup' );
|
||||
wp_clear_scheduled_hook( 'wpdo_archive_sweep' );
|
||||
// v2.14.0: clear all plugin cron hooks (was incomplete pre-v2.14.0).
|
||||
wp_clear_scheduled_hook( 'wpdo_errors_gc' );
|
||||
wp_clear_scheduled_hook( 'wpdo_flush_views' );
|
||||
wp_clear_scheduled_hook( 'wpdo_remove_uae_plugin_dir' );
|
||||
wp_clear_scheduled_hook( 'wpdo_daily_health_check' );
|
||||
wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' );
|
||||
wp_clear_scheduled_hook( 'wpdo_fsm_automator_run' );
|
||||
wp_clear_scheduled_hook( 'wpdo_collect_site_metrics' );
|
||||
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( 'wpdo_post_stress_test_batch' );
|
||||
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
|
||||
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
|
||||
wp_clear_scheduled_hook( 'wpdo_comment_stress_test_batch' );
|
||||
foreach ( self::CRON_HOOKS as $hook ) {
|
||||
wp_unschedule_hook( $hook );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── v2.14.0: Multisite-aware site cleanup ─────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -965,40 +992,26 @@ class TMDO_Installer {
|
||||
// Sweep any remaining wpdo_* options (stragglers introduced post-v2.14.0
|
||||
// or by 3rd-party hooks). Validates name pattern before delete to avoid
|
||||
// accidental option removal.
|
||||
$residual = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
|
||||
$wpdb->esc_like( 'wpdo_' ) . '%'
|
||||
)
|
||||
);
|
||||
foreach ( (array) $residual as $opt ) {
|
||||
if ( preg_match( '/^wpdo_[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
|
||||
++$options_deleted;
|
||||
// 1.4.2: tmdo_* options and their transients too (tmdo_schema_deferred,
|
||||
// tmdo_entity_indexes, tmdo_cov_* …) — a reinstall saw stale ones.
|
||||
// tmdo_latepoint_* belongs to the LatePoint add-on.
|
||||
foreach ( array( 'wpdo_', 'tmdo_', '_transient_tmdo_', '_transient_timeout_tmdo_' ) as $prefix ) {
|
||||
$residual = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
|
||||
$wpdb->esc_like( $prefix ) . '%'
|
||||
)
|
||||
);
|
||||
foreach ( (array) $residual as $opt ) {
|
||||
if ( preg_match( '/^(_transient_(timeout_)?)?(wpdo|tmdo)_(?!latepoint_)[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
|
||||
++$options_deleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5) Clear cron events ──────────────────────────────────────────────
|
||||
$crons = array(
|
||||
'wpdo_warm_cleanup',
|
||||
'wpdo_archive_sweep',
|
||||
'wpdo_errors_gc',
|
||||
'wpdo_flush_views',
|
||||
'wpdo_remove_uae_plugin_dir',
|
||||
'wpdo_daily_health_check',
|
||||
'wpdo_snapshot_prune_daily',
|
||||
'wpdo_fsm_automator_run',
|
||||
'wpdo_collect_site_metrics',
|
||||
'wpdo_health_snapshot_monthly',
|
||||
'wpdo_post_shadow_verify',
|
||||
'wpdo_term_comment_shadow_verify',
|
||||
'wpdo_post_stress_test_batch',
|
||||
'wpdo_user_stress_test_batch',
|
||||
'wpdo_term_stress_test_batch',
|
||||
'wpdo_comment_stress_test_batch',
|
||||
);
|
||||
foreach ( $crons as $hook ) {
|
||||
if ( false !== wp_next_scheduled( $hook ) ) {
|
||||
wp_clear_scheduled_hook( $hook );
|
||||
foreach ( self::CRON_HOOKS as $hook ) {
|
||||
if ( wp_unschedule_hook( $hook ) ) {
|
||||
++$crons_cleared;
|
||||
}
|
||||
}
|
||||
@@ -1436,7 +1449,8 @@ class TMDO_Installer {
|
||||
original_meta_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_post_id (post_id),
|
||||
KEY idx_post_type_date (post_type, archived_at)
|
||||
KEY idx_post_type_date (post_type, archived_at),
|
||||
KEY idx_original_meta (original_meta_id)
|
||||
) {$charset};";
|
||||
|
||||
foreach ( $sqls as $sql ) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -872,7 +872,6 @@ final class TMDO_Post_Stress_Tester {
|
||||
global $wpdb;
|
||||
$prefix = $wpdb->prefix . 'wpdo_post_';
|
||||
$map = array(
|
||||
'product' => $prefix . 'wc_product',
|
||||
'hp_listing' => $prefix . 'hp_listing_core',
|
||||
'hp_request' => $prefix . 'hp_request_core',
|
||||
'hp_vendor' => $prefix . 'hp_vendor_core',
|
||||
@@ -1036,7 +1035,6 @@ final class TMDO_Post_Stress_Tester {
|
||||
return array(
|
||||
$prefix . 'wp_core',
|
||||
$prefix . 'attachment',
|
||||
$prefix . 'wc_product',
|
||||
$prefix . 'hp_listing_core',
|
||||
$prefix . 'hp_request_core',
|
||||
$prefix . 'hp_vendor_core',
|
||||
|
||||
@@ -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(
|
||||
@@ -718,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(
|
||||
|
||||
@@ -282,7 +282,8 @@ class TMDO_Schema_Registry {
|
||||
* 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), whatever it declares.
|
||||
* 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
|
||||
@@ -291,7 +292,9 @@ class TMDO_Schema_Registry {
|
||||
$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'] ?? '' ) );
|
||||
&& 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'] ?? '' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -325,7 +325,20 @@ final class TMDO_Entity_Health {
|
||||
return TMDO_Mode_Manager::ALL_MODES[ $idx - 1 ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a backfill batch is scheduled for any group of the entity. Batches
|
||||
* are scheduled with (entity, group) args, so looking for (entity) alone
|
||||
* never found one (2.4.2 PR-137).
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_backfill_active( string $entity_type ): bool {
|
||||
return (bool) wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type ) );
|
||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
|
||||
if ( wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type, $group ) ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -175,6 +176,9 @@ final class TMDO_Entity_Migration_Engine {
|
||||
// 標記完成
|
||||
if ( ! $options['dry_run'] ) {
|
||||
self::mark_migration_completed( $entity_type, $group_name );
|
||||
if ( $stats['migrated'] > 0 ) {
|
||||
self::counts_changed( $entity_type );
|
||||
}
|
||||
}
|
||||
|
||||
$stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 );
|
||||
@@ -182,6 +186,45 @@ final class TMDO_Entity_Migration_Engine {
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows rebuilt outside the Hook Bus (backfill, drift-check --fix): routed
|
||||
* post counts cached for the old rows are stale (2.4 PR-129).
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @return void
|
||||
*/
|
||||
private static function counts_changed( string $entity_type ): void {
|
||||
if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Query_Router' ) ) {
|
||||
TMDO_Post_Query_Router::bump_count_version();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
$migrated = self::migrate_single_entity(
|
||||
$entity_type,
|
||||
$group_name,
|
||||
$entity_id,
|
||||
array_column( $group_fields, 'key' ),
|
||||
array_column( $group_fields, null, 'key' ),
|
||||
$adapter,
|
||||
false
|
||||
);
|
||||
if ( $migrated ) {
|
||||
self::counts_changed( $entity_type );
|
||||
}
|
||||
return $migrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 遷移單一 entity 的所有 meta
|
||||
*/
|
||||
@@ -243,6 +286,27 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
// REPLACE drops columns it is not given: keep a post row's status/date
|
||||
// copies, or one NULL row turns the copy route off for the group (2.4 PR-127).
|
||||
if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Status_Sync' ) ) {
|
||||
foreach ( TMDO_Post_Status_Sync::insert_columns( $entity_id, $group_name ) as $col => $value ) {
|
||||
$data[ $col ] = $value;
|
||||
$formats[] = '%s';
|
||||
}
|
||||
}
|
||||
|
||||
if ( $dry_run ) {
|
||||
return true;
|
||||
}
|
||||
@@ -349,13 +413,25 @@ 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;
|
||||
}
|
||||
|
||||
$total = $prev_total + $batch_migrated;
|
||||
$done = count( $entity_ids ) < $batch_size;
|
||||
if ( $batch_migrated > 0 ) {
|
||||
self::counts_changed( $entity_type );
|
||||
}
|
||||
|
||||
if ( $done ) {
|
||||
self::mark_migration_completed( $entity_type, $group_name );
|
||||
@@ -374,6 +450,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 ) );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 斷點管理
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -159,9 +159,12 @@ final class TMDO_Entity_Registry {
|
||||
self::$scopes[ $entity_type ][ $group_name ] = $post_types;
|
||||
}
|
||||
$keys = array_column( $normalized, 'key' );
|
||||
// A sort index may end with the entity ID (flat-first tie-break, 2.4 PR-118).
|
||||
$id_col = (string) self::$adapters[ $entity_type ]->get_entity_id_column();
|
||||
foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) {
|
||||
$columns = array_values( array_map( 'strval', (array) $columns ) );
|
||||
if ( $columns && ! array_diff( $columns, $keys ) ) {
|
||||
$fields = '' !== $id_col && end( $columns ) === $id_col ? array_slice( $columns, 0, -1 ) : $columns;
|
||||
if ( $fields && ! array_diff( $fields, $keys ) ) {
|
||||
self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,6 +912,7 @@ final class TMDO_Hook_Bus {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -948,10 +949,23 @@ final class TMDO_Hook_Bus {
|
||||
*/
|
||||
private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
|
||||
if ( null === $field_def || 'post' !== $type ) {
|
||||
return $field_def;
|
||||
if ( null !== $field_def && 'post' === $type ) {
|
||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
|
||||
}
|
||||
return 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 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -969,10 +983,13 @@ 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;
|
||||
@@ -980,7 +997,8 @@ final class TMDO_Hook_Bus {
|
||||
|
||||
$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'] );
|
||||
|
||||
@@ -159,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,
|
||||
|
||||
@@ -29,7 +29,10 @@ final class TMDO_Post_Status_Sync {
|
||||
|
||||
public const OPTION = 'tmdo_post_status_columns';
|
||||
|
||||
private const INDEX = 'idx_post_status_date';
|
||||
private const INDEX = 'idx_post_status_date_id';
|
||||
|
||||
/** The 2.2–2.3 (post_status, post_date) index, replaced by INDEX. */
|
||||
private const LEGACY_INDEX = 'idx_post_status_date';
|
||||
|
||||
/**
|
||||
* Hook the sync into WordPress.
|
||||
@@ -65,6 +68,10 @@ final class TMDO_Post_Status_Sync {
|
||||
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.
|
||||
@@ -156,6 +163,10 @@ final class TMDO_Post_Status_Sync {
|
||||
}
|
||||
self::set_state( $group, 'ready' );
|
||||
}
|
||||
// Routed counts cached while the copies were stale must go.
|
||||
if ( ! $dry_run && class_exists( 'TMDO_Post_Query_Router' ) ) {
|
||||
TMDO_Post_Query_Router::bump_count_version();
|
||||
}
|
||||
return $report;
|
||||
}
|
||||
|
||||
@@ -199,7 +210,12 @@ final class TMDO_Post_Status_Sync {
|
||||
$changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL';
|
||||
}
|
||||
if ( ! in_array( self::INDEX, $indexes, true ) ) {
|
||||
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`)';
|
||||
// Ends with post_id: the router reads the date route's ID tie-break
|
||||
// from it, so MySQL walks the index and stops at the LIMIT (2.4 PR-128).
|
||||
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`, `post_id`)';
|
||||
}
|
||||
if ( in_array( self::LEGACY_INDEX, $indexes, true ) ) {
|
||||
$changes[] = 'DROP INDEX `' . self::LEGACY_INDEX . '`';
|
||||
}
|
||||
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;
|
||||
@@ -220,6 +236,20 @@ final class TMDO_Post_Status_Sync {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -23,6 +23,12 @@ 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();
|
||||
|
||||
@@ -77,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;
|
||||
}
|
||||
|
||||
@@ -92,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();
|
||||
|
||||
@@ -144,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 );
|
||||
|
||||
@@ -157,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: 表建立/升級完成
|
||||
@@ -176,6 +254,12 @@ final class TMDO_Schema_Manager {
|
||||
* 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
|
||||
@@ -193,23 +277,249 @@ final class TMDO_Schema_Manager {
|
||||
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;
|
||||
}
|
||||
|
||||
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
|
||||
foreach ( $declared as $name => $keys ) {
|
||||
$index = 'cidx_' . self::sanitize_column_name( $name );
|
||||
if ( in_array( $index, $existing, true ) ) {
|
||||
continue;
|
||||
}
|
||||
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
|
||||
$wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" );
|
||||
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,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$applied[ $id ] = $signature;
|
||||
update_option( self::INDEXES_OPTION, $applied, true );
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -84,7 +84,7 @@ final class TMDO_Type_Caster {
|
||||
}
|
||||
|
||||
/** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */
|
||||
private const TEXT_MAX_CHARS = 255;
|
||||
public const TEXT_MAX_CHARS = 255;
|
||||
|
||||
/**
|
||||
* Keep a text value only if it fits its VARCHAR(255) column; otherwise
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
/**
|
||||
* Post entity field registration for WP Data Optimizer (v2.9.1).
|
||||
*
|
||||
* Registers four 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)
|
||||
* 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 four 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,
|
||||
@@ -39,7 +38,10 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers four 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 {
|
||||
|
||||
@@ -49,7 +51,6 @@ final class TMDO_Post_Fields {
|
||||
*/
|
||||
private const GROUP_POST_TYPES = array(
|
||||
'attachment' => 'attachment',
|
||||
'wc_product' => 'product',
|
||||
'nav_menu_item' => 'nav_menu_item',
|
||||
);
|
||||
|
||||
@@ -104,7 +105,6 @@ final class TMDO_Post_Fields {
|
||||
|
||||
self::register_wp_core_group();
|
||||
self::register_attachment_group();
|
||||
self::register_wc_product_group();
|
||||
self::register_nav_menu_item_group();
|
||||
}
|
||||
|
||||
@@ -176,131 +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',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nav menu item meta keys (post_type=nav_menu_item).
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
@@ -536,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.
|
||||
*
|
||||
|
||||
@@ -26,13 +26,17 @@
|
||||
* 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.
|
||||
* post_date (ID tie-breaks read as the flat post_id), so MySQL can walk
|
||||
* `idx_post_status_date_id` 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 posts' last_changed moves (core
|
||||
* post saves, and flat writes via TMDO_Hook_Bus).
|
||||
* 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'.
|
||||
@@ -69,6 +73,12 @@ class TMDO_Post_Query_Router {
|
||||
/** 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' );
|
||||
|
||||
@@ -95,6 +105,38 @@ class TMDO_Post_Query_Router {
|
||||
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 ) ) {
|
||||
continue;
|
||||
}
|
||||
// With the status / date copies: this runs after transition_post_status,
|
||||
// and one NULL copy turns the copy route off for the whole group.
|
||||
$copies = class_exists( 'TMDO_Post_Status_Sync' ) ? TMDO_Post_Status_Sync::insert_columns( (int) $post_id, $group ) : array();
|
||||
if ( $copies ) {
|
||||
$wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`, `post_status`, `post_date`) VALUES (%d, %s, %s)", (int) $post_id, $copies['post_status'], $copies['post_date'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
continue;
|
||||
}
|
||||
$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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +146,11 @@ class TMDO_Post_Query_Router {
|
||||
* @return void
|
||||
*/
|
||||
public function pre_get_posts( \WP_Query $query ): void {
|
||||
// The router's query vars are its own: drop any a caller passed in.
|
||||
foreach ( array( self::QUERY_VAR, self::SORT_VAR, self::COPY_VAR, self::COUNT_VAR ) as $var ) {
|
||||
$query->set( $var, '' );
|
||||
}
|
||||
|
||||
// Mode gate: only run when post mode is reads_from_flat.
|
||||
if ( ! self::is_router_active() ) {
|
||||
return;
|
||||
@@ -138,8 +185,11 @@ class TMDO_Post_Query_Router {
|
||||
|
||||
// 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 )
|
||||
// A key comparison other than equality (meta_compare_key LIKE …) matches
|
||||
// other keys too: not a test on one flat column.
|
||||
$meta_key = $query->get( 'meta_key' );
|
||||
$key_exact = in_array( (string) $query->get( 'meta_compare_key' ), array( '', '=' ), true );
|
||||
$take_key = $key_exact && 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 );
|
||||
@@ -158,6 +208,27 @@ class TMDO_Post_Query_Router {
|
||||
$routed[ $pt ][] = $route;
|
||||
$sort = self::sort_for( $pt, $route, $direction );
|
||||
}
|
||||
// meta_key alone (no meta_value, not ordered by): WP_Query adds a clause
|
||||
// that tests the key exists — HivePress's featured-listing lookup. Any
|
||||
// orderby naming meta_value, meta_value_num or the key sorts by that
|
||||
// clause, which clearing meta_key would drop.
|
||||
$exists_key = ! $take_key && $key_exact && is_string( $meta_key ) && '' !== $meta_key
|
||||
&& '' === (string) $query->get( 'meta_value' ) && '' === (string) $query->get( 'meta_compare' )
|
||||
&& ! array_intersect( $named, array( 'meta_value', 'meta_value_num', $meta_key ) )
|
||||
&& null !== self::field_for( array( 'key' => $meta_key ), $pt );
|
||||
if ( $exists_key ) {
|
||||
$route = self::route_clause(
|
||||
array(
|
||||
'key' => $meta_key,
|
||||
'compare' => 'EXISTS',
|
||||
),
|
||||
self::field_for( array( 'key' => $meta_key ), $pt )
|
||||
);
|
||||
if ( ! $route ) {
|
||||
return;
|
||||
}
|
||||
$routed[ $pt ][] = $route;
|
||||
}
|
||||
|
||||
foreach ( $raw_meta_query as $k => $clause ) {
|
||||
if ( 'relation' === $k || ! is_array( $clause ) ) {
|
||||
@@ -166,7 +237,14 @@ class TMDO_Post_Query_Router {
|
||||
}
|
||||
if ( ! isset( $clause['key'] ) ) {
|
||||
// Sort shape: matches every post, only orders by the key.
|
||||
$name = 'AND' === $relation ? self::sort_shape( $clause ) : '';
|
||||
$name = 'AND' === $relation ? self::sort_shape( $clause ) : '';
|
||||
// Matches every post and orders nothing here (a copy of a search's meta
|
||||
// query) — unless orderby names it, or meta_value(_num) / its key, which
|
||||
// sort by it when it is WordPress's primary (first) clause.
|
||||
if ( '' !== $name && ! array_intersect( $named, array( $name, 'meta_value', 'meta_value_num', (string) ( $clause[ $name ]['key'] ?? '' ) ) )
|
||||
&& self::field_for( $clause[ $name ], $pt ) ) {
|
||||
continue;
|
||||
}
|
||||
$field = '' !== $name && $name === $sort_name ? self::field_for( $clause[ $name ], $pt ) : null;
|
||||
if ( $field ) {
|
||||
$route = self::route_clause( array( 'compare' => 'EXISTS' ) + $clause[ $name ], $field );
|
||||
@@ -206,7 +284,7 @@ class TMDO_Post_Query_Router {
|
||||
if ( empty( $routed ) && empty( $sort ) ) {
|
||||
return;
|
||||
}
|
||||
if ( $take_key ) {
|
||||
if ( $take_key || $exists_key ) {
|
||||
$query->set( 'meta_key', '' );
|
||||
}
|
||||
if ( $sort ) {
|
||||
@@ -324,7 +402,7 @@ class TMDO_Post_Query_Router {
|
||||
if ( empty( $copy ) ) {
|
||||
return $orderby;
|
||||
}
|
||||
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
|
||||
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . ( 'ASC' === $copy['order'] ? 'ASC' : 'DESC' );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,7 +423,23 @@ class TMDO_Post_Query_Router {
|
||||
if ( ! $counted ) {
|
||||
return $request;
|
||||
}
|
||||
$matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
|
||||
global $wpdb;
|
||||
$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;
|
||||
}
|
||||
@@ -387,13 +481,21 @@ class TMDO_Post_Query_Router {
|
||||
if ( empty( $count_sql ) ) {
|
||||
return (string) ( $sql ?? '' );
|
||||
}
|
||||
$key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' );
|
||||
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
|
||||
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 ) {
|
||||
global $wpdb;
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -401,6 +503,17 @@ class TMDO_Post_Query_Router {
|
||||
// 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).
|
||||
*
|
||||
@@ -422,6 +535,57 @@ class TMDO_Post_Query_Router {
|
||||
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.
|
||||
*
|
||||
@@ -523,9 +687,30 @@ class TMDO_Post_Query_Router {
|
||||
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.
|
||||
* 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
|
||||
@@ -538,11 +723,17 @@ class TMDO_Post_Query_Router {
|
||||
*/
|
||||
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 );
|
||||
// Without 'value', WP_Meta_Query checks only that the key exists (it adds a
|
||||
// value condition only when one is set): HivePress's featured-listing
|
||||
// lookup, run on every search page, is array( 'key' => 'hp_featured' ).
|
||||
if ( ! array_key_exists( 'value', $clause ) && ! in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
|
||||
$compare = 'EXISTS';
|
||||
}
|
||||
$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;
|
||||
@@ -560,7 +751,7 @@ class TMDO_Post_Query_Router {
|
||||
&& 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 ) {
|
||||
if ( ! $ok || self::drifting( (string) $field['group'] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -581,7 +772,92 @@ class TMDO_Post_Query_Router {
|
||||
* @return string
|
||||
*/
|
||||
private static function routed_request( string $request, \WP_Query $query ): string {
|
||||
return self::flat_first( self::without_found_rows( $request ), $query );
|
||||
return self::copy_ordered( self::flat_first( self::without_found_rows( $request ), $query ), $query );
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy route's request without WordPress's GROUP BY (when the flat row
|
||||
* is the only join) and with posts.ID tie-breaks read as the flat post_id,
|
||||
* each in its own direction, once. MariaDB then walks the
|
||||
* (post_status, post_date, post_id) index and stops at the LIMIT instead
|
||||
* of sorting every match (dev36, 235k of 1.3M listings: 1.7 s → 3 ms).
|
||||
*
|
||||
* @param string $request SELECT statement.
|
||||
* @param \WP_Query $query Current WP_Query.
|
||||
* @return string
|
||||
*/
|
||||
private static function copy_ordered( string $request, \WP_Query $query ): string {
|
||||
$copy = $query->get( self::COPY_VAR );
|
||||
if ( empty( $copy ) ) {
|
||||
return $request;
|
||||
}
|
||||
return self::ties_on_post_id( self::without_group_by( $request ), self::table_alias( $copy['post_type'], $copy['group'] ), '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* ORDER BY terms that are posts.ID alone (with or without a direction)
|
||||
* read as the equal flat post_id — in $direction, or each in its own when
|
||||
* $direction is '' — and only once. posts.ID inside an expression a plugin
|
||||
* added (FIELD(), IN …) is left alone: a direction inserted there breaks
|
||||
* the SQL.
|
||||
*
|
||||
* @param string $request SELECT statement.
|
||||
* @param string $alias Flat table alias.
|
||||
* @param string $direction 'ASC', 'DESC' or '' (keep each term's own).
|
||||
* @return string
|
||||
*/
|
||||
private static function ties_on_post_id( string $request, string $alias, string $direction ): string {
|
||||
global $wpdb;
|
||||
$tie = "`{$alias}`.`post_id` ";
|
||||
$id = '`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?';
|
||||
return self::replaced(
|
||||
preg_replace_callback(
|
||||
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
|
||||
static function ( $m ) use ( $id, $tie, $direction ) {
|
||||
$order = self::replaced(
|
||||
preg_replace_callback(
|
||||
'/(?<=ORDER BY |,)(\s*)' . $id . '(?:\s+(ASC|DESC)\b)?(?=\s*(?:,|\bLIMIT\b|$))/i',
|
||||
static fn( $t ) => $t[1] . $tie . ( '' !== $direction ? $direction : ( 'DESC' === strtoupper( $t[2] ?? '' ) ? 'DESC' : 'ASC' ) ),
|
||||
$m[0]
|
||||
),
|
||||
$m[0]
|
||||
);
|
||||
return self::replaced( preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . '(ASC|DESC))(\s*,\s*' . preg_quote( $tie, '/' ) . '\2)+/', '$1', $order ), $order );
|
||||
},
|
||||
$request
|
||||
),
|
||||
$request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A preg_* result, or the text it was given when PCRE failed (backtrack
|
||||
* or JIT stack limit on a very long request): the query stays unrouted
|
||||
* in shape rather than empty.
|
||||
*
|
||||
* @param string|null $result preg_* return value.
|
||||
* @param string $fallback Input text.
|
||||
* @return string
|
||||
*/
|
||||
private static function replaced( ?string $result, string $fallback ): string {
|
||||
return $result ?? $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* WordPress groups by posts.ID for any tax or meta query. With a 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).
|
||||
*
|
||||
* @param string $request SELECT statement.
|
||||
* @return string
|
||||
*/
|
||||
private static function without_group_by( string $request ): string {
|
||||
global $wpdb;
|
||||
if ( 1 !== preg_match_all( '/\bJOIN\b/i', $request ) ) {
|
||||
return $request;
|
||||
}
|
||||
return self::replaced( preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', $request, 1 ), $request );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -603,14 +879,25 @@ class TMDO_Post_Query_Router {
|
||||
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( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
|
||||
if ( empty( $sort ) || ! is_array( $sort ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
|
||||
return $request;
|
||||
}
|
||||
$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 ( empty( $sort ) || 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) {
|
||||
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 ) ) ) {
|
||||
@@ -622,8 +909,8 @@ class TMDO_Post_Query_Router {
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $groups[0] );
|
||||
$alias = self::table_alias( $post_type, $groups[0] );
|
||||
$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 ) ) {
|
||||
@@ -639,13 +926,14 @@ class TMDO_Post_Query_Router {
|
||||
if ( 1 !== $replaced ) {
|
||||
return $request;
|
||||
}
|
||||
// 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 );
|
||||
$flat_first = self::without_group_by( (string) $flat_first );
|
||||
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat
|
||||
// post_id, once, in the sort's direction: a (column, post_id) index then
|
||||
// returns rows in order with no filesort. Ties are ordered by ID the same
|
||||
// way as the column (natively ID DESC); pagination stays stable.
|
||||
$direction = 'ASC' === strtoupper( (string) $sort['order'] ) ? 'ASC' : 'DESC';
|
||||
$flat_first = self::ties_on_post_id( $flat_first, $alias, $direction );
|
||||
return self::replaced( preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', $flat_first, 1 ), $request );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -655,7 +943,7 @@ class TMDO_Post_Query_Router {
|
||||
* @return string
|
||||
*/
|
||||
private static function without_found_rows( string $request ): string {
|
||||
return (string) preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
|
||||
return self::replaced( preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 ), $request );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -210,7 +210,15 @@ class TMDO_Zone_Archive {
|
||||
global $wpdb;
|
||||
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
|
||||
// Rows already archived are skipped: the sweep used to copy the same
|
||||
// trashed meta again every day (2.4.2 PR-136).
|
||||
$table = self::table();
|
||||
// Without the index each candidate scans the archive: wait for `wp tmdo install`.
|
||||
if ( ! TMDO_IS_SQLITE && ! $wpdb->get_var( "SHOW INDEX FROM `{$table}` WHERE Key_name = 'idx_original_meta'" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- internal table name
|
||||
return 0;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
|
||||
@@ -218,12 +226,14 @@ class TMDO_Zone_Archive {
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_status = 'trash'
|
||||
AND p.post_modified_gmt < %s
|
||||
AND NOT EXISTS ( SELECT 1 FROM `{$table}` a WHERE a.original_meta_id = pm.meta_id )
|
||||
LIMIT %d",
|
||||
$cutoff,
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
|
||||
if ( ! $rows ) {
|
||||
return 0;
|
||||
@@ -245,6 +255,21 @@ class TMDO_Zone_Archive {
|
||||
return count( $entries );
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete archive rows that copy the same original meta row again, keeping
|
||||
* the first copy (sweeps before 1.4.2 archived trashed meta every day).
|
||||
* Rows without an original meta id are left alone.
|
||||
*
|
||||
* @return int Rows deleted.
|
||||
*/
|
||||
public static function remove_duplicates(): int {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
|
||||
$deleted = $wpdb->query( "DELETE FROM `{$table}` WHERE original_meta_id > 0 AND id NOT IN ( SELECT keep_id FROM ( SELECT MIN(id) AS keep_id FROM `{$table}` WHERE original_meta_id > 0 GROUP BY original_meta_id ) AS k )" );
|
||||
return is_int( $deleted ) ? $deleted : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get archive statistics.
|
||||
*
|
||||
|
||||
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
|
||||
+48
-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.1.1
|
||||
Stable tag: 1.4.3
|
||||
License: GPLv2 or later
|
||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
|
||||
@@ -107,6 +107,53 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.4.3 =
|
||||
* 重新打包(無功能變更):與 WP-MVB v8 樞紐站(dev39)一同驗收後發版;程式碼與前一版相同。
|
||||
|
||||
= 1.4.2 =
|
||||
* The settings page shows why a bridge-mode change was refused (unsafe jump, Promotion Guard) instead of reporting it saved
|
||||
* Archive sweep skips meta already archived; `wp tmdo install` adds an index on original_meta_id and removes duplicate archive rows (the sweep waits until the index exists)
|
||||
* Entity health sees a running backfill (batches are scheduled with entity and group args)
|
||||
* Deactivate and uninstall clear every scheduled event, including ones scheduled with args; uninstall also removes tmdo_* options and transients
|
||||
* The refused-uninstall notice points to `wp tmdo bridge-restore-native`; the post stress tester no longer uses the retired wc_product table
|
||||
|
||||
= 1.4.1 =
|
||||
* A query ordered by its meta_key among several orderings (or by the key's name) keeps meta_key native; meta_compare_key other than '=' is not routed; a sort group stays when orderby may use it as the primary clause
|
||||
* Only standalone posts.ID terms in ORDER BY become flat post_id tie-breaks (FIELD()/IN expressions from other plugins are left alone); PCRE failures keep the original SQL
|
||||
* Router query vars preset by callers are dropped
|
||||
* New posts' flat rows get their status/date copies; post-status-sync invalidates cached routed counts
|
||||
|
||||
= 1.4.0 =
|
||||
* Flat-first sorts read the ID tie-break from the flat post_id, so a declared (column, post_id) index returns rows in order; ties now follow the sort direction
|
||||
* Meta clauses without a value route as EXISTS; HivePress's featured-listing lookup on every search page uses the flat table
|
||||
* Date-ordered routed searches drop the redundant GROUP BY and use a (post_status, post_date, post_id) index (replace the index on the tables listed in `tmdo_post_status_columns`; `wp tmdo post-status-sync` also adds the copy columns to every other post group)
|
||||
* Rows rebuilt by the migration engine keep their status/date copies and invalidate cached routed counts
|
||||
* Live test tools no longer flush the object cache
|
||||
|
||||
= 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
|
||||
|
||||
+9
-5
@@ -180,7 +180,7 @@ if ( ! function_exists( 'do_action_ref_array' ) ) {
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'wp_next_scheduled' ) ) {
|
||||
function wp_next_scheduled( string $hook ): int|false { return false; }
|
||||
function wp_next_scheduled( string $hook, array $args = array() ): int|false { return $GLOBALS['_wp_next_scheduled'][ $hook ][ md5( serialize( $args ) ) ] ?? false; }
|
||||
}
|
||||
if ( ! function_exists( 'wp_schedule_event' ) ) {
|
||||
function wp_schedule_event( int $t, string $r, string $h ): bool { return true; }
|
||||
@@ -188,6 +188,7 @@ if ( ! function_exists( 'wp_schedule_event' ) ) {
|
||||
if ( ! function_exists( 'wp_schedule_single_event' ) ) {
|
||||
function wp_schedule_single_event( int $ts, string $hook ): bool { return true; }
|
||||
}
|
||||
if ( ! function_exists( 'wp_unschedule_hook' ) ) { function wp_unschedule_hook( string $hook ): int|false { $GLOBALS['_wp_unscheduled'][] = $hook; return 0; } }
|
||||
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) {
|
||||
function wp_clear_scheduled_hook( string $hook ): int|false { return 0; }
|
||||
}
|
||||
@@ -506,10 +507,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 {}
|
||||
}
|
||||
}
|
||||
@@ -622,6 +625,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';
|
||||
|
||||
@@ -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,320 @@
|
||||
<?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' );
|
||||
// One CRON_HOOKS list serves deactivate and uninstall (InstallerCleanupTest covers both).
|
||||
$this->assertSame( 1, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'listed in CRON_HOOKS' );
|
||||
}
|
||||
|
||||
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'] );
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ class GroupCompositeIndexTest extends TestCase {
|
||||
$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 );
|
||||
@@ -53,6 +57,120 @@ class GroupCompositeIndexTest extends TestCase {
|
||||
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 */
|
||||
|
||||
@@ -227,6 +227,30 @@ class HookBusIntegrationTest extends TestCase {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -68,6 +68,50 @@ class InstallerCleanupTest extends TestCase {
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Events scheduled with args (tmdo_drift_resync( 'post' ), backfill
|
||||
* batches) are not cleared by wp_clear_scheduled_hook( $hook ); the user
|
||||
* stress hook was misnamed and three hooks were missing (2.4.2 PR-138, F-4).
|
||||
*/
|
||||
private const ARG_AND_MISSED_HOOKS = array( 'tmdo_drift_resync', 'wpdo_entity_backfill_batch', 'wpdo_stress_test_batch', 'wpdo_migration_tick', 'wpdo_auto_promote_check' );
|
||||
|
||||
public function test_deactivate_unschedules_every_hook_whatever_its_args(): void {
|
||||
$GLOBALS['_wp_unscheduled'] = array();
|
||||
|
||||
WPDO_Installer::deactivate();
|
||||
|
||||
foreach ( self::ARG_AND_MISSED_HOOKS as $hook ) {
|
||||
$this->assertContains( $hook, $GLOBALS['_wp_unscheduled'], $hook );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_uninstall_unschedules_every_hook_whatever_its_args(): void {
|
||||
$GLOBALS['_wp_unscheduled'] = array();
|
||||
|
||||
WPDO_Installer::drop_all_tables_for_current_blog();
|
||||
|
||||
foreach ( self::ARG_AND_MISSED_HOOKS as $hook ) {
|
||||
$this->assertContains( $hook, $GLOBALS['_wp_unscheduled'], $hook );
|
||||
}
|
||||
}
|
||||
|
||||
/** tmdo_* options and transients were left behind; a reinstall then saw stale deferrals and indexes (F-5). */
|
||||
public function test_uninstall_removes_tmdo_options_and_transients(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( 'tmdo_schema_deferred', 'tmdo_entity_indexes', '_transient_tmdo_cov_x', '_transient_timeout_tmdo_cov_x', 'hivepress_settings', 'tmdo_latepoint_db_version' ) as $name ) {
|
||||
$wpdb->insert( $wpdb->options, array( 'option_name' => $name, 'option_value' => '1' ) );
|
||||
$GLOBALS['_wp_options'][ $name ] = '1';
|
||||
}
|
||||
|
||||
WPDO_Installer::drop_all_tables_for_current_blog();
|
||||
|
||||
foreach ( array( 'tmdo_schema_deferred', 'tmdo_entity_indexes', '_transient_tmdo_cov_x', '_transient_timeout_tmdo_cov_x' ) as $name ) {
|
||||
$this->assertArrayNotHasKey( $name, $GLOBALS['_wp_options'], $name );
|
||||
}
|
||||
$this->assertArrayHasKey( 'hivepress_settings', $GLOBALS['_wp_options'], 'other plugins untouched' );
|
||||
$this->assertArrayHasKey( 'tmdo_latepoint_db_version', $GLOBALS['_wp_options'], 'the LatePoint add-on owns it' );
|
||||
}
|
||||
|
||||
public function test_drops_static_tables(): void {
|
||||
global $wpdb;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -31,7 +31,7 @@ class PostQueryRouterCopyTest extends TestCase {
|
||||
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( '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_id (post_status, post_date, post_id) ) 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' ) );
|
||||
@@ -58,6 +58,104 @@ class PostQueryRouterCopyTest extends TestCase {
|
||||
$mode->setValue( null, null );
|
||||
}
|
||||
|
||||
/**
|
||||
* The full request WordPress would build for the routed search, after posts_request.
|
||||
*
|
||||
* @param string $extra_join Another JOIN (a tax query's).
|
||||
* @param string $ties ID tie-breaks plugins appended.
|
||||
*/
|
||||
private function request( string $extra_join = '', string $ties = ', {posts}.ID DESC, {posts}.ID DESC' ): string {
|
||||
global $wpdb;
|
||||
$ties = str_replace( '{posts}', $wpdb->posts, $ties );
|
||||
$query = new WP_Query();
|
||||
foreach ( array(
|
||||
'post_type' => 'rc_item',
|
||||
'post_status' => 'publish',
|
||||
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
|
||||
) as $k => $v ) {
|
||||
$query->set( $k, $v );
|
||||
}
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
$router->pre_get_posts( $query );
|
||||
$request = 'SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts ' . $router->posts_join( $extra_join, $query )
|
||||
. ' WHERE 1=1 ' . $router->posts_where( " AND wp_posts.post_status = 'publish'", $query )
|
||||
. " GROUP BY {$wpdb->posts}.ID ORDER BY " . $router->posts_orderby( 'wp_posts.post_date DESC', $query ) . $ties . ' LIMIT 0, 10';
|
||||
return $router->posts_request( $request, $query );
|
||||
}
|
||||
|
||||
/**
|
||||
* 2.4 PR-128: with GROUP BY or a posts.ID tie-break MariaDB sorts every
|
||||
* match (dev36, 235k of 1.3M listings: 1.7 s); without them it walks
|
||||
* (post_status, post_date, post_id) backwards and stops at the LIMIT (3 ms).
|
||||
*/
|
||||
public function test_copy_route_orders_by_the_flat_date_and_post_id_without_group_by(): void {
|
||||
$sql = $this->request();
|
||||
|
||||
$this->assertStringNotContainsString( 'GROUP BY', $sql, 'one flat row per post: nothing to collapse' );
|
||||
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql );
|
||||
}
|
||||
|
||||
public function test_copy_route_keeps_each_tie_break_direction(): void {
|
||||
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` ASC LIMIT 0, 10', $this->request( '', ', {posts}.ID ASC' ) );
|
||||
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC LIMIT 0, 10', $this->request( '', '' ) );
|
||||
}
|
||||
|
||||
public function test_copy_route_leaves_posts_id_inside_expressions_alone(): void {
|
||||
global $wpdb;
|
||||
$sql = $this->request( '', ', FIELD({posts}.ID,5,9) DESC, ({posts}.ID IN (3,4)) DESC, {posts}.ID DESC' );
|
||||
|
||||
$this->assertStringEndsWith( "`post_date` DESC, FIELD({$wpdb->posts}.ID,5,9) DESC, ({$wpdb->posts}.ID IN (3,4)) DESC, " . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* The router's query vars are its own: values a caller passed in
|
||||
* (new WP_Query( array( 'wpdo_post_copy_route' => … ) )) are dropped
|
||||
* (2.4 security review L2).
|
||||
*/
|
||||
public function test_query_vars_passed_by_a_caller_are_dropped(): void {
|
||||
$query = new WP_Query();
|
||||
foreach ( array(
|
||||
'post_type' => 'rc_item',
|
||||
'orderby' => 'title',
|
||||
'wpdo_post_copy_route' => array( 'post_type' => 'rc_item', 'group' => 'rc_core', 'statuses' => array( 'publish' ), 'order' => 'DESC, (SELECT 1)' ),
|
||||
'wpdo_post_sort' => array( 'post_type' => 'rc_item', 'group' => 'rc_core', 'column' => 'rc_price', 'order' => 'ASC' ),
|
||||
'wpdo_post_clauses' => array( 'rc_item' => array() ),
|
||||
'wpdo_post_count_sql' => 'SELECT 1',
|
||||
) as $k => $v ) {
|
||||
$query->set( $k, $v );
|
||||
}
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
$router->pre_get_posts( $query );
|
||||
|
||||
foreach ( array( 'wpdo_post_copy_route', 'wpdo_post_sort', 'wpdo_post_clauses', 'wpdo_post_count_sql' ) as $var ) {
|
||||
$this->assertEmpty( $query->get( $var ), $var );
|
||||
}
|
||||
$this->assertSame( 'wp_posts.post_title ASC', $router->posts_orderby( 'wp_posts.post_title ASC', $query ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* wp_insert_post runs after transition_post_status: a new post's empty flat
|
||||
* row must carry the copies itself, or one NULL turns the copy route off for
|
||||
* the whole group (2.4 correctness review).
|
||||
*/
|
||||
public function test_a_new_post_gets_its_flat_row_with_the_copies(): void {
|
||||
global $wpdb;
|
||||
$post = new WP_Post( (object) array( 'ID' => 7, 'post_type' => 'rc_item', 'post_status' => 'draft', 'post_date' => '2026-02-02 00:00:00' ) );
|
||||
$GLOBALS['_wp_posts'][7] = $post;
|
||||
|
||||
( new TMDO_Post_Query_Router() )->add_flat_rows( 7, $post, false );
|
||||
|
||||
$this->assertSame( array( 'post_status' => 'draft', 'post_date' => '2026-02-02 00:00:00' ), $wpdb->get_row( 'SELECT post_status, post_date FROM `' . self::TABLE . '` WHERE post_id = 7', ARRAY_A ) );
|
||||
unset( $GLOBALS['_wp_posts'][7] );
|
||||
}
|
||||
|
||||
public function test_copy_route_keeps_group_by_with_another_join(): void {
|
||||
global $wpdb;
|
||||
$sql = $this->request( " LEFT JOIN wp_term_relationships ON ({$wpdb->posts}.ID = wp_term_relationships.object_id)" );
|
||||
|
||||
$this->assertStringContainsString( "GROUP BY {$wpdb->posts}.ID", $sql );
|
||||
}
|
||||
|
||||
/** @return array{join:string, where:string, orderby:string} */
|
||||
private function sql( array $vars ): array {
|
||||
$query = new WP_Query();
|
||||
|
||||
@@ -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' ) );
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,17 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
||||
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();
|
||||
|
||||
@@ -117,13 +128,85 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
||||
$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();
|
||||
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' );
|
||||
|
||||
@@ -132,6 +215,7 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
||||
|
||||
$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 ─────────────
|
||||
@@ -142,14 +226,14 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
||||
*
|
||||
* @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string}
|
||||
*/
|
||||
private function sorted( string $compare = '>=' ): array {
|
||||
private function sorted( string $compare = '>=', string $order = 'DESC' ): 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' );
|
||||
$query->set( 'order', $order );
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
$router->pre_get_posts( $query );
|
||||
|
||||
@@ -168,11 +252,45 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
||||
$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->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id, once' );
|
||||
$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 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID tie-break follows the sort direction, so a (column, post_id)
|
||||
* index returns rows in order with no filesort (2.4 PR-118); ties are
|
||||
* ordered by ID the same way as the column.
|
||||
*/
|
||||
public function test_flat_first_tie_break_follows_the_sort_direction(): void {
|
||||
global $wpdb;
|
||||
list( $router, $query, $request ) = $this->sorted( '>=', 'ASC' );
|
||||
|
||||
$sql = $router->posts_request( $request, $query );
|
||||
|
||||
$this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` ASC, `wpdoflat_fr_item_fr_core`.`post_id` ASC LIMIT 0, 10', $sql );
|
||||
$this->assertSame( array( '2', '3' ), $wpdb->get_col( $sql ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Only standalone posts.ID terms are tie-breaks: inside an expression a
|
||||
* plugin added (pinning posts with FIELD() or IN) the direction must not be
|
||||
* inserted, or the SQL breaks (2.4 correctness review).
|
||||
*/
|
||||
public function test_flat_first_leaves_posts_id_inside_expressions_alone(): void {
|
||||
global $wpdb;
|
||||
list( $router, $query, $request ) = $this->sorted();
|
||||
$pinned = str_replace( ' LIMIT 0, 10', ", FIELD({$wpdb->posts}.ID,3,2) DESC, ({$wpdb->posts}.ID IN (2)) DESC LIMIT 0, 10", $request );
|
||||
|
||||
$sql = $router->posts_request( $pinned, $query );
|
||||
|
||||
$this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql );
|
||||
$this->assertStringContainsString( "FIELD({$wpdb->posts}.ID,3,2) DESC, ({$wpdb->posts}.ID IN (2)) DESC LIMIT", $sql );
|
||||
$wpdb->last_error = '';
|
||||
$this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $pinned ) ), $wpdb->get_col( $sql ) );
|
||||
$this->assertSame( '', $wpdb->last_error );
|
||||
}
|
||||
|
||||
public function test_date_ordered_filtered_request_keeps_wordpress_join_order(): void {
|
||||
list( $router, $query, $request ) = $this->routed();
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ class PostQueryRouterOrderTest 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();
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
|
||||
@@ -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 {
|
||||
@@ -411,4 +412,126 @@ class PostQueryRouterTest extends TestCase {
|
||||
|
||||
$this->assertStringContainsString( "LIKE '%B1\\\\_1\\\\%%'", $router->posts_where( '', $query ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* A clause with no value tests only that the key exists (WP_Meta_Query adds
|
||||
* a value condition only when 'value' is set), so it routes as EXISTS.
|
||||
* HivePress's featured-listing lookup on every search page is one:
|
||||
* array( 'key' => 'hp_featured' ) — it kept the whole query native (2.4 PR-122).
|
||||
*/
|
||||
public function test_a_clause_without_a_value_routes_as_exists(): 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' => '_stock' ),
|
||||
array( 'key' => '_price', 'value' => array( 10, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
$router->pre_get_posts( $query );
|
||||
|
||||
$this->assertEmpty( $query->get( 'meta_query' ), 'both clauses routed' );
|
||||
$this->assertStringContainsString( '`_stock` IS NOT NULL', $router->posts_where( '', $query ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress's featured-listing lookup (Attribute::set_search_query()): the
|
||||
* search's meta query, including its sort group, plus meta_key hp_featured,
|
||||
* ordered at random. The sort group (key NOT EXISTS OR key EXISTS) matches
|
||||
* every post, so unused for ordering it is dropped; meta_key without a
|
||||
* value or a meta_value sort tests that the key exists (2.4 PR-122).
|
||||
*/
|
||||
public function test_hivepress_featured_lookup_is_routed(): void {
|
||||
$this->set_post_mode( 'aeav_only' );
|
||||
$router = new WPDO_Post_Query_Router();
|
||||
$query = $this->make_query(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_stock',
|
||||
'orderby' => 'rand',
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'relation' => 'OR',
|
||||
'price__order' => array( 'key' => '_price', 'compare' => 'NOT EXISTS', 'type' => 'DECIMAL' ),
|
||||
array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'DECIMAL' ),
|
||||
),
|
||||
array( 'key' => '_price', 'value' => array( 10, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
$router->pre_get_posts( $query );
|
||||
|
||||
$this->assertSame( '', $query->get( 'meta_key' ), 'meta_key routed' );
|
||||
$this->assertEmpty( array_filter( (array) $query->get( 'meta_query' ), 'is_array' ), 'sort group dropped, filter routed' );
|
||||
$where = $router->posts_where( '', $query );
|
||||
$this->assertStringContainsString( '`_stock` IS NOT NULL', $where );
|
||||
$this->assertStringContainsString( 'BETWEEN', $where );
|
||||
}
|
||||
|
||||
/**
|
||||
* WordPress orders by the primary meta clause (meta_key) for meta_value,
|
||||
* meta_value_num or the key's name anywhere in orderby; routing the key and
|
||||
* clearing meta_key would drop that ordering (2.4 correctness review).
|
||||
*/
|
||||
public function test_meta_key_ordered_by_among_several_orderings_stays_native(): void {
|
||||
foreach ( array(
|
||||
array( 'meta_value_num' => 'ASC', 'title' => 'ASC' ),
|
||||
'meta_value date',
|
||||
'_stock',
|
||||
) as $orderby ) {
|
||||
$this->set_post_mode( 'aeav_only' );
|
||||
$query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'orderby' => $orderby ) );
|
||||
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||
|
||||
$label = is_array( $orderby ) ? implode( ',', array_keys( $orderby ) ) : $orderby;
|
||||
$this->assertSame( '_stock', $query->get( 'meta_key' ), $label );
|
||||
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $label );
|
||||
}
|
||||
}
|
||||
|
||||
/** meta_compare_key LIKE matches other keys too: not a test on one flat column. */
|
||||
public function test_meta_key_with_a_key_comparison_stays_native(): void {
|
||||
foreach ( array( 'rand', 'meta_value_num' ) as $orderby ) {
|
||||
$this->set_post_mode( 'aeav_only' );
|
||||
$query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'meta_compare_key' => 'LIKE', 'orderby' => $orderby ) );
|
||||
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||
|
||||
$this->assertSame( '_stock', $query->get( 'meta_key' ), $orderby );
|
||||
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $orderby );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Without meta_key the first clause is WordPress's primary one: orderby
|
||||
* meta_value_num sorts by the sort group's key, so the group stays.
|
||||
*/
|
||||
public function test_sort_group_ordered_as_the_primary_clause_is_not_dropped(): void {
|
||||
$this->assert_not_routed(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'orderby' => 'meta_value_num',
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'relation' => 'OR',
|
||||
'price__order' => array( 'key' => '_price', 'compare' => 'NOT EXISTS', 'type' => 'DECIMAL' ),
|
||||
array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'DECIMAL' ),
|
||||
),
|
||||
array( 'key' => '_stock', 'value' => 1, 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||
),
|
||||
),
|
||||
'primary sort group'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_meta_key_with_a_value_is_left_alone(): void {
|
||||
$this->set_post_mode( 'aeav_only' );
|
||||
$router = new WPDO_Post_Query_Router();
|
||||
$query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'meta_value' => '5' ) );
|
||||
$router->pre_get_posts( $query );
|
||||
|
||||
$this->assertSame( '_stock', $query->get( 'meta_key' ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ class PostStatusSyncTest extends TestCase {
|
||||
'includes/adapters/class-tmdo-adapter-post.php',
|
||||
'includes/engine/class-tmdo-hook-bus.php',
|
||||
'includes/engine/class-tmdo-post-status-sync.php',
|
||||
'includes/engine/class-tmdo-entity-migration-engine.php',
|
||||
'includes/integrations/class-tmdo-post-fields.php',
|
||||
'includes/query/class-tmdo-post-query-router.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
@@ -81,9 +84,34 @@ class PostStatusSyncTest extends TestCase {
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function has_index( string $table ): bool {
|
||||
/** @return array<string, string[]> index name → columns */
|
||||
private function indexes( string $table ): array {
|
||||
global $wpdb;
|
||||
return in_array( 'idx_post_status_date', array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ), true );
|
||||
$out = array();
|
||||
foreach ( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ) as $row ) {
|
||||
$out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function has_index( string $table ): bool {
|
||||
return array( 'post_status', 'post_date', 'post_id' ) === ( $this->indexes( $table )['idx_post_status_date_id'] ?? null );
|
||||
}
|
||||
|
||||
/**
|
||||
* 2.4 PR-128: the date route's ID tie-break is read from post_id, which the
|
||||
* index must end with; the 2.2 two-column index is replaced, not kept beside it.
|
||||
*/
|
||||
public function test_run_replaces_the_two_column_index_of_existing_tables(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( 'ALTER TABLE `' . self::A . '` ADD COLUMN post_status VARCHAR(20) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL, ADD COLUMN post_date DATETIME DEFAULT NULL, ADD INDEX idx_post_status_date (post_status, post_date)' );
|
||||
$GLOBALS['_wp_options'][ TMDO_Post_Status_Sync::OPTION ] = array( 'ps_a' => 'ready' );
|
||||
|
||||
TMDO_Post_Status_Sync::run();
|
||||
|
||||
$this->assertTrue( $this->has_index( self::A ) );
|
||||
$this->assertArrayNotHasKey( 'idx_post_status_date', $this->indexes( self::A ) );
|
||||
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
||||
}
|
||||
|
||||
public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void {
|
||||
@@ -104,6 +132,16 @@ class PostStatusSyncTest extends TestCase {
|
||||
$this->assertSame( 2, $report['ps_a']['mismatched'] );
|
||||
}
|
||||
|
||||
/** Routed counts cached while copies were stale are dropped by a sync (2.4 correctness review). */
|
||||
public function test_a_sync_invalidates_cached_routed_counts_but_a_dry_run_does_not(): void {
|
||||
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
|
||||
TMDO_Post_Status_Sync::run( true );
|
||||
$this->assertSame( 'before', $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] );
|
||||
|
||||
TMDO_Post_Status_Sync::run();
|
||||
$this->assertNotSame( 'before', $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] );
|
||||
}
|
||||
|
||||
public function test_copies_use_the_posts_collation_so_they_compare_with_posts(): void {
|
||||
global $wpdb;
|
||||
TMDO_Post_Status_Sync::run();
|
||||
@@ -134,6 +172,23 @@ class PostStatusSyncTest extends TestCase {
|
||||
$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 = '';
|
||||
@@ -156,6 +211,64 @@ class PostStatusSyncTest extends TestCase {
|
||||
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The migration engine (backfill, drift-check --fix) REPLACEs whole rows:
|
||||
* without the copies, a rebuilt row had NULL post_status, which turns the
|
||||
* status/date copy route off for the whole group (2.4 PR-127).
|
||||
*/
|
||||
public function test_a_row_rebuilt_from_native_keeps_its_copies(): void {
|
||||
global $wpdb;
|
||||
TMDO_Post_Status_Sync::run();
|
||||
$GLOBALS['_wp_posts'][2] = (object) array( 'ID' => 2, 'post_type' => 'hp_listing', 'post_status' => 'draft', 'post_date' => '2026-02-01 10:00:00' );
|
||||
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' );
|
||||
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$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) ) ENGINE=InnoDB" );
|
||||
try {
|
||||
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native'), (3, 'k', 'a3-native')" );
|
||||
|
||||
WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ); // Existing row.
|
||||
WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 3 ); // New row.
|
||||
} finally {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
|
||||
}
|
||||
|
||||
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2] );
|
||||
$this->assertSame( array( 'pending', '2026-03-01 10:00:00' ), $this->copies( self::A )[3] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfills and drift-check --fix rebuild rows outside the Hook Bus: the
|
||||
* routed counts cached for the old rows must go (2.4 PR-129; dev36: a
|
||||
* vendor search kept counting 21515 of 21522 matches after a fix).
|
||||
*/
|
||||
public function test_rebuilding_post_rows_invalidates_cached_routed_counts(): void {
|
||||
global $wpdb;
|
||||
$status = $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status';
|
||||
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$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) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$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" );
|
||||
$version = static fn() => $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] ?? null;
|
||||
try {
|
||||
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native')" );
|
||||
|
||||
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
|
||||
TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0, 'dry_run' => true ) );
|
||||
$this->assertSame( 'before', $version(), 'a dry run writes nothing' );
|
||||
|
||||
foreach ( array(
|
||||
'migrate_entity' => static fn() => TMDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ),
|
||||
'migrate_group' => static fn() => TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0 ) ),
|
||||
'migrate_group_batch' => static fn() => TMDO_Entity_Migration_Engine::migrate_group_batch( 'post', 'ps_a' ),
|
||||
) as $path => $run ) {
|
||||
TMDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'ps_a' );
|
||||
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
|
||||
$run();
|
||||
$this->assertNotSame( 'before', $version(), $path );
|
||||
}
|
||||
} finally {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$status}`" );
|
||||
}
|
||||
}
|
||||
|
||||
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' );
|
||||
|
||||
@@ -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'] );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* TMDO_Shadow_Diff_Logger throttling (2.2 PR-075).
|
||||
*
|
||||
* The per-object window (one row per entity + key per 5 minutes) did not stop
|
||||
* a key that is stale on every object — WooCommerce's total_sales, written by
|
||||
* direct SQL — from logging a row for each product read. A key now records at
|
||||
* most KEY_LIMIT rows per KEY_WINDOW across all objects.
|
||||
*/
|
||||
class ShadowDiffThrottleTest extends TestCase {
|
||||
|
||||
private const DIFFS = 'wp_itest_wpdo_shadow_diffs';
|
||||
private const ERRORS = 'wp_itest_wpdo_errors';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
'includes/engine/class-tmdo-shadow-diff-logger.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::DIFFS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ts DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', entity_type VARCHAR(20) NOT NULL DEFAULT '', entity_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) NOT NULL DEFAULT '', postmeta_value LONGTEXT, zone_value LONGTEXT, diff_hash VARCHAR(40) NOT NULL DEFAULT '', PRIMARY KEY (id) ) ENGINE=InnoDB" );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::ERRORS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, module VARCHAR(100) NOT NULL DEFAULT '', zone VARCHAR(20) NOT NULL DEFAULT '', hook VARCHAR(255) NOT NULL DEFAULT '', message TEXT NOT NULL, context LONGTEXT, created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id) ) ENGINE=InnoDB" );
|
||||
for ( $id = 1; $id <= 30; $id++ ) {
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'total_sales', 'meta_value' => '5' ) );
|
||||
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'other_key', 'meta_value' => '5' ) );
|
||||
}
|
||||
|
||||
$GLOBALS['_wp_transients'] = array();
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( $wpdb->usermeta, self::DIFFS, self::ERRORS ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
private function logged( string $key ): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM `' . self::DIFFS . '` WHERE meta_key = %s', $key ) );
|
||||
}
|
||||
|
||||
private function stale_read( int $user_id, string $key ): void {
|
||||
TMDO_Shadow_Diff_Logger::compare_and_log( 'user', $user_id, $key, 4, array( 'type' => 'integer' ) );
|
||||
}
|
||||
|
||||
public function test_a_key_stale_on_every_object_logs_at_most_the_key_limit(): void {
|
||||
for ( $id = 1; $id <= 30; $id++ ) {
|
||||
$this->stale_read( $id, 'total_sales' );
|
||||
}
|
||||
|
||||
$this->assertSame( TMDO_Shadow_Diff_Logger::KEY_LIMIT, $this->logged( 'total_sales' ) );
|
||||
}
|
||||
|
||||
public function test_the_limit_is_per_key(): void {
|
||||
for ( $id = 1; $id <= 30; $id++ ) {
|
||||
$this->stale_read( $id, 'total_sales' );
|
||||
}
|
||||
$this->stale_read( 1, 'other_key' );
|
||||
|
||||
$this->assertSame( 1, $this->logged( 'other_key' ) );
|
||||
}
|
||||
|
||||
public function test_the_same_object_and_key_still_log_once_per_window(): void {
|
||||
$this->stale_read( 1, 'total_sales' );
|
||||
$this->stale_read( 1, 'total_sales' );
|
||||
|
||||
$this->assertSame( 1, $this->logged( 'total_sales' ) );
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ class ZoneArchiveIntegrationTest extends TestCase {
|
||||
`original_meta_id` bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `post_id` (`post_id`),
|
||||
KEY `archived_at` (`archived_at`)
|
||||
KEY `archived_at` (`archived_at`),
|
||||
KEY `idx_original_meta` (`original_meta_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||
);
|
||||
}
|
||||
@@ -45,6 +46,69 @@ class ZoneArchiveIntegrationTest extends TestCase {
|
||||
$GLOBALS['_wp_postmeta'] = [];
|
||||
}
|
||||
|
||||
// ── sweep / duplicates (2.4.2 PR-136, manual F-6) ─────────────────────
|
||||
|
||||
/** Posts and postmeta for sweep(): post 10 trashed 200 days ago, post 11 published. */
|
||||
private function seed_trashed_post(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
|
||||
$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_modified_gmt DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', 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) ) ENGINE=InnoDB" );
|
||||
$old = gmdate( 'Y-m-d H:i:s', time() - 200 * DAY_IN_SECONDS );
|
||||
$wpdb->query( "INSERT INTO `{$wpdb->posts}` VALUES (10, 'hp_listing', 'trash', '{$old}'), (11, 'hp_listing', 'publish', '{$old}')" );
|
||||
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (10, 'hp_price', '5'), (10, 'hp_rating', '4'), (11, 'hp_price', '9')" );
|
||||
}
|
||||
|
||||
private function drop_posts(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
|
||||
}
|
||||
|
||||
private function archive_rows(): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' );
|
||||
}
|
||||
|
||||
public function test_sweep_copies_each_trashed_meta_row_once(): void {
|
||||
$this->seed_trashed_post();
|
||||
try {
|
||||
$this->assertSame( 2, WPDO_Zone_Archive::sweep( 90, false ) );
|
||||
$this->assertSame( 0, WPDO_Zone_Archive::sweep( 90, false ), 'already archived' );
|
||||
$this->assertSame( 2, $this->archive_rows() );
|
||||
} finally {
|
||||
$this->drop_posts();
|
||||
}
|
||||
}
|
||||
|
||||
/** Until `wp tmdo install` adds the index, NOT EXISTS would scan the archive per candidate. */
|
||||
public function test_sweep_waits_for_the_original_meta_index(): void {
|
||||
global $wpdb;
|
||||
$this->seed_trashed_post();
|
||||
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` DROP INDEX `idx_original_meta`' );
|
||||
try {
|
||||
$this->assertSame( 0, WPDO_Zone_Archive::sweep( 90, false ) );
|
||||
$this->assertSame( 0, $this->archive_rows() );
|
||||
} finally {
|
||||
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` ADD KEY `idx_original_meta` (`original_meta_id`)' );
|
||||
$this->drop_posts();
|
||||
}
|
||||
}
|
||||
|
||||
public function test_remove_duplicates_keeps_the_first_copy_of_each_meta_row(): void {
|
||||
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 );
|
||||
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 );
|
||||
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 );
|
||||
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 );
|
||||
WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' ); // original_meta_id 0: no identity, kept.
|
||||
WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' );
|
||||
|
||||
$this->assertSame( 2, WPDO_Zone_Archive::remove_duplicates() );
|
||||
$this->assertSame( 4, $this->archive_rows() );
|
||||
$this->assertSame( 0, WPDO_Zone_Archive::remove_duplicates() );
|
||||
}
|
||||
|
||||
// ── archive / get ─────────────────────────────────────────────────────
|
||||
|
||||
public function test_archive_stores_plain_entry(): void {
|
||||
|
||||
@@ -168,7 +168,12 @@ $wpdb = new class( $_mysqli ) {
|
||||
fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'",
|
||||
array_values( $data )
|
||||
) );
|
||||
$ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" );
|
||||
try {
|
||||
$ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" );
|
||||
} catch ( mysqli_sql_exception $e ) { // Like wpdb::insert(): an SQL error returns false.
|
||||
$this->last_error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; }
|
||||
$this->last_error = (string) $this->db->error;
|
||||
return false;
|
||||
@@ -200,7 +205,12 @@ $wpdb = new class( $_mysqli ) {
|
||||
public function flush(): void {}
|
||||
|
||||
public function query( string $sql ): int|bool {
|
||||
$result = $this->db->query( $sql );
|
||||
try {
|
||||
$result = $this->db->query( $sql );
|
||||
} catch ( mysqli_sql_exception $e ) { // Like wpdb::query(): an SQL error returns false.
|
||||
$this->last_error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
if ( $result instanceof mysqli_result ) { $result->free(); return true; }
|
||||
if ( false === $result ) { return false; }
|
||||
return $this->db->affected_rows;
|
||||
@@ -289,9 +299,23 @@ if ( ! function_exists( 'do_action_ref_array' ) ) {
|
||||
if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } }
|
||||
if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } }
|
||||
if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } }
|
||||
if ( ! function_exists( 'get_metadata' ) ) {
|
||||
// Like get_metadata(): the first stored value of a key (single), read from the real meta table.
|
||||
function get_metadata( string $type, $object_id, string $key = '', bool $single = false ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->{$type . 'meta'};
|
||||
$id = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||
$value = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$type}_id` = %d AND meta_key = %s ORDER BY `{$id}` LIMIT 1", $object_id, $key ) );
|
||||
if ( $single ) {
|
||||
return null === $value ? '' : maybe_unserialize( $value );
|
||||
}
|
||||
return null === $value ? array() : array( maybe_unserialize( $value ) );
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
|
||||
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
|
||||
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } }
|
||||
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } }
|
||||
if ( ! function_exists( 'wp_unschedule_hook' ) ) { function wp_unschedule_hook( string $hook ): int|false { $GLOBALS['_wp_unscheduled'][] = $hook; return 0; } }
|
||||
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } }
|
||||
if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } }
|
||||
if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } }
|
||||
@@ -331,6 +355,19 @@ if ( ! function_exists( 'get_term_meta' ) ) { function get_term_meta( int $tid,
|
||||
if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } }
|
||||
if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } }
|
||||
if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } }
|
||||
if ( ! class_exists( 'WP_Post' ) ) {
|
||||
final class WP_Post {
|
||||
public $ID = 0;
|
||||
public $post_type = 'post';
|
||||
public $post_status = 'publish';
|
||||
public $post_date = '0000-00-00 00:00:00';
|
||||
public function __construct( $post ) {
|
||||
foreach ( get_object_vars( $post ) as $k => $v ) {
|
||||
$this->$k = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'get_post' ) ) { function get_post( $post_id ) { return $GLOBALS['_wp_posts'][ (int) $post_id ] ?? null; } }
|
||||
if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
|
||||
if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
|
||||
@@ -411,6 +448,7 @@ if ( ! function_exists( 'wp_mkdir_p' ) ) {
|
||||
|
||||
$GLOBALS['_wp_cache'] = [];
|
||||
if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } }
|
||||
if ( ! function_exists( 'wp_cache_set_last_changed' ) ) { function wp_cache_set_last_changed( $group ) { $GLOBALS['_wp_last_changed'][ $group ] = (string) hrtime( true ); return $GLOBALS['_wp_last_changed'][ $group ]; } }
|
||||
if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } }
|
||||
if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } }
|
||||
if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
|
||||
@@ -517,10 +555,12 @@ if ( ! function_exists( 'wp_insert_comment' ) ) {
|
||||
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +626,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/engine/class-tmdo-auto-promoter.php';
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
|
||||
@@ -646,3 +687,43 @@ if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
|
||||
}
|
||||
class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' );
|
||||
}
|
||||
|
||||
/**
|
||||
* The WooCommerce product group core registered before 1.2.0 (PR-077 moved
|
||||
* product meta back to native only). Tests that use products as a routed
|
||||
* post type register it themselves; the table name wp_itest_wpdo_post_wc_product
|
||||
* is unchanged.
|
||||
*/
|
||||
function tmdo_test_register_wc_product_group(): void {
|
||||
$enum = static fn( string $key, array $options ): array => array(
|
||||
'key' => $key,
|
||||
'type' => 'enum',
|
||||
'options' => $options,
|
||||
);
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'wc_product',
|
||||
array(
|
||||
array( 'key' => '_price', 'type' => 'decimal', 'searchable' => true ),
|
||||
array( 'key' => '_regular_price', 'type' => 'decimal', 'searchable' => true ),
|
||||
array( 'key' => '_sale_price', 'type' => 'decimal', 'searchable' => true ),
|
||||
array( 'key' => '_stock', 'type' => 'integer', 'searchable' => true ),
|
||||
array( 'key' => '_stock_status', 'type' => 'enum', 'searchable' => true, 'options' => array( 'instock', 'outofstock', 'onbackorder' ) ),
|
||||
array( 'key' => '_sku', 'type' => 'text', 'searchable' => true ),
|
||||
$enum( '_manage_stock', array( 'yes', 'no' ) ),
|
||||
$enum( '_backorders', array( 'yes', 'no', 'notify' ) ),
|
||||
$enum( '_sold_individually', array( 'yes', 'no' ) ),
|
||||
$enum( '_virtual', array( 'yes', 'no' ) ),
|
||||
$enum( '_downloadable', array( 'yes', 'no' ) ),
|
||||
array( 'key' => '_tax_class', 'type' => 'text' ),
|
||||
$enum( '_tax_status', array( 'taxable', 'shipping', 'none' ) ),
|
||||
array( 'key' => '_download_limit', 'type' => 'integer' ),
|
||||
array( 'key' => '_download_expiry', 'type' => 'integer' ),
|
||||
array( 'key' => '_product_version', 'type' => 'text' ),
|
||||
array( 'key' => '_wc_average_rating', 'type' => 'decimal', 'searchable' => true ),
|
||||
array( 'key' => '_wc_review_count', 'type' => 'integer' ),
|
||||
array( 'key' => 'total_sales', 'type' => 'integer', 'searchable' => true ),
|
||||
),
|
||||
array( 'post_types' => array( 'product' ) )
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test:
|
||||
| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) |
|
||||
| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route, meta orderby: HivePress sort shape, named clause, meta_key + meta_value_num); routed SQL has no GROUP BY |
|
||||
| `rest` | anonymous GET of the public listings endpoints returns no protected or credential-like field (1.1.1) |
|
||||
| `drift` | `TMDO_Drift_Check`: json groups show no false diff; a planted flat mismatch / flat-only value counts exactly once (1.2.0) |
|
||||
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
|
||||
|
||||
## Tools (`tools/`)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* Suite: TMDO_Drift_Check on a real site (2.2 PR-070).
|
||||
*
|
||||
* - json groups (attachment / nav_menu_item) compare by value: a serialized
|
||||
* native array and its JSON flat copy are not drift.
|
||||
* - a flat value planted behind the checker's back shows up as exactly one
|
||||
* diff / one extra, nothing else moves.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Run the drift check for one group and put the site's recorded status back.
|
||||
*
|
||||
* @param string $entity Entity type.
|
||||
* @param string $group Group.
|
||||
* @return array<string, mixed> Report.
|
||||
*/
|
||||
function tmdo_live_drift( string $entity, string $group ): array {
|
||||
$status = get_option( TMDO_Drift_Check::OPTION, null );
|
||||
try {
|
||||
return TMDO_Drift_Check::run( $entity, $group );
|
||||
} finally {
|
||||
null === $status ? delete_option( TMDO_Drift_Check::OPTION ) : update_option( TMDO_Drift_Check::OPTION, $status );
|
||||
}
|
||||
}
|
||||
|
||||
TMDO_Live::test(
|
||||
'drift-check reports no diff for json groups whose values match native',
|
||||
static function () {
|
||||
$checked = 0;
|
||||
foreach ( array( 'attachment', 'nav_menu_item' ) as $group ) {
|
||||
if ( ! in_array( $group, TMDO_Entity_Migration_Engine::backfillable_groups( 'post' ), true )
|
||||
|| ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( 'post', $group ) ) ) {
|
||||
continue;
|
||||
}
|
||||
$report = tmdo_live_drift( 'post', $group );
|
||||
tmdo_live_assert_same( 0, $report['total']['diff'], "post/{$group} diff" );
|
||||
++$checked;
|
||||
}
|
||||
if ( 0 === $checked ) {
|
||||
throw new TMDO_Live_Skip( 'no attachment / nav_menu_item flat table on this site' );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
TMDO_Live::test(
|
||||
'drift-check counts a planted flat mismatch once and a flat-only value once',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
TMDO_Live_Fixtures::use_modes( array( 'user' => 'disabled' ) );
|
||||
$field = TMDO_Live_Fixtures::field( 'user', 'login_count' );
|
||||
$group = $field['group'];
|
||||
$table = TMDO_Schema_Manager::get_table_name( 'user', $group );
|
||||
|
||||
$before = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count'];
|
||||
|
||||
$mismatch = TMDO_Live_Fixtures::user();
|
||||
$flat_only = TMDO_Live_Fixtures::user();
|
||||
add_user_meta( $mismatch, 'login_count', '5' );
|
||||
try {
|
||||
$wpdb->insert( $table, array( 'user_id' => $mismatch, 'login_count' => 6 ) );
|
||||
$wpdb->insert( $table, array( 'user_id' => $flat_only, 'login_count' => 9 ) );
|
||||
|
||||
$after = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count'];
|
||||
} finally {
|
||||
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE user_id IN (%d, %d)", $mismatch, $flat_only ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
|
||||
tmdo_live_assert_same( $before['diff'] + 1, $after['diff'], 'login_count diff +1' );
|
||||
tmdo_live_assert_same( $before['extra'] + 1, $after['extra'], 'login_count extra +1' );
|
||||
tmdo_live_assert_same( $before['miss'], $after['miss'], 'login_count miss unchanged' );
|
||||
}
|
||||
);
|
||||
@@ -247,21 +247,22 @@ TMDO_Live::test(
|
||||
TMDO_Live::test(
|
||||
'Q06 LIKE matches substrings and escapes wildcards, routed',
|
||||
static function () {
|
||||
TMDO_Live_Fixtures::field( 'post', '_sku', 'product' );
|
||||
// A text key of wp_core (WooCommerce's _sku left the flat tables in 1.2.0).
|
||||
TMDO_Live_Fixtures::field( 'post', '_wp_page_template' );
|
||||
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||
$p = tmdo_live_router_posts(
|
||||
'product',
|
||||
'post',
|
||||
array(
|
||||
'P1' => array( '_sku' => 'tmdo-live-B1-1' ),
|
||||
'P2' => array( '_sku' => 'tmdo-live-B1-10' ),
|
||||
'P3' => array( '_sku' => 'tmdo-live-B2-1' ),
|
||||
'P1' => array( '_wp_page_template' => 'tmdo-live-B1-1' ),
|
||||
'P2' => array( '_wp_page_template' => 'tmdo-live-B1-10' ),
|
||||
'P3' => array( '_wp_page_template' => 'tmdo-live-B2-1' ),
|
||||
)
|
||||
);
|
||||
foreach ( array( 'B1-1', 'B1_1' ) as $needle ) {
|
||||
tmdo_live_router_assert_native(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_query' => array( array( 'key' => '_sku', 'value' => $needle, 'compare' => 'LIKE' ) ),
|
||||
'post_type' => 'post',
|
||||
'meta_query' => array( array( 'key' => '_wp_page_template', 'value' => $needle, 'compare' => 'LIKE' ) ),
|
||||
),
|
||||
array_values( $p ),
|
||||
true
|
||||
@@ -423,3 +424,160 @@ TMDO_Live::test(
|
||||
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request );
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* HivePress-style sort-only search (rating__order), fields ids, first page.
|
||||
* DECIMAL(10,1): what the HivePress Rating field (decimals 1) sends.
|
||||
*
|
||||
* @param string $order ASC | DESC.
|
||||
* @return array<string, mixed> WP_Query args.
|
||||
*/
|
||||
function tmdo_live_router_rating_sort( string $order ): array {
|
||||
return array(
|
||||
'post_type' => 'hp_listing',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 10,
|
||||
'fields' => 'ids',
|
||||
'suppress_filters' => false,
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'relation' => 'OR',
|
||||
'rating__order' => array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'NOT EXISTS' ),
|
||||
array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'EXISTS' ),
|
||||
),
|
||||
),
|
||||
'orderby' => 'rating__order',
|
||||
'order' => $order,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The native sort key of each post, in order (null without a rating). On the
|
||||
* whole site many listings share a rating and native orders ties by chance,
|
||||
* so a page is compared by its keys, not its ids.
|
||||
*
|
||||
* @param array<int|string> $ids Post ids.
|
||||
* @return array<int, string|null>
|
||||
*/
|
||||
function tmdo_live_router_rating_keys( array $ids ): array {
|
||||
global $wpdb;
|
||||
$ids = array_map( 'intval', $ids );
|
||||
if ( empty( $ids ) ) {
|
||||
return array();
|
||||
}
|
||||
$rows = $wpdb->get_results(
|
||||
"SELECT post_id, CAST(meta_value AS DECIMAL(10,1)) AS k FROM {$wpdb->postmeta} WHERE meta_key = 'hp_rating' AND post_id IN (" . implode( ',', $ids ) . ')',
|
||||
OBJECT_K
|
||||
);
|
||||
return array_map( static fn( $id ) => isset( $rows[ $id ] ) ? (string) $rows[ $id ]->k : null, $ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Native and routed runs of the same query.
|
||||
*
|
||||
* @param array<string, mixed> $args Query args.
|
||||
* @return array{0:WP_Query, 1:WP_Query}
|
||||
*/
|
||||
function tmdo_live_router_native_and_routed( array $args ): array {
|
||||
$native = new WP_Query( $args );
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
$router->register_hooks();
|
||||
try {
|
||||
$routed = new WP_Query( $args );
|
||||
} finally {
|
||||
tmdo_live_router_unhook( $router );
|
||||
}
|
||||
return array( $native, $routed );
|
||||
}
|
||||
|
||||
TMDO_Live::test(
|
||||
'a sort-only search drives from the flat table when every post of the type has a flat row (2.2 PR-079)',
|
||||
static function () {
|
||||
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
|
||||
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||
TMDO_Post_Query_Router::forget_coverage();
|
||||
foreach ( array( 'DESC', 'ASC' ) as $order ) {
|
||||
list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( $order ) );
|
||||
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), "{$order} sort keys" );
|
||||
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, "{$order} found_posts" );
|
||||
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), "{$order} flat-first: " . $routed->request );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
TMDO_Live::test(
|
||||
'a sort-only search keeps the LEFT JOIN while a post of the type has no flat row (2.2 PR-079)',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
|
||||
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||
$bare = TMDO_Live_Fixtures::post( 'hp_listing' );
|
||||
$wpdb->delete( TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' ), array( 'post_id' => $bare ) );
|
||||
TMDO_Post_Query_Router::forget_coverage();
|
||||
try {
|
||||
list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( 'DESC' ) );
|
||||
} finally {
|
||||
TMDO_Post_Query_Router::forget_coverage();
|
||||
}
|
||||
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
|
||||
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
|
||||
tmdo_live_assert_true( false === strpos( $routed->request, 'STRAIGHT_JOIN' ), 'not flat-first: ' . $routed->request );
|
||||
}
|
||||
);
|
||||
|
||||
TMDO_Live::test(
|
||||
'a post created while the router is active gets its empty flat row (2.2 PR-079)',
|
||||
static function () {
|
||||
global $wpdb;
|
||||
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
|
||||
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||
$router = new TMDO_Post_Query_Router();
|
||||
$router->register_hooks();
|
||||
try {
|
||||
$id = TMDO_Live_Fixtures::post( 'hp_listing' );
|
||||
} finally {
|
||||
tmdo_live_router_unhook( $router );
|
||||
}
|
||||
$table = TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' );
|
||||
tmdo_live_assert_same( '1', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE post_id = %d", $id ) ), 'flat row created' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
}
|
||||
);
|
||||
|
||||
TMDO_Live::test(
|
||||
'a flat-first search drops the GROUP BY WordPress adds for a subquery-only tax query (2.2 PR-079)',
|
||||
static function () {
|
||||
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
|
||||
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||
TMDO_Post_Query_Router::forget_coverage();
|
||||
// Like WooCommerce's product_visibility exclusion on front-end searches: NOT IN
|
||||
// is a subquery, yet WordPress groups by posts.ID for any tax query.
|
||||
$args = tmdo_live_router_rating_sort( 'DESC' ) + array(
|
||||
'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => array( 999999999 ), 'operator' => 'NOT IN' ) ),
|
||||
);
|
||||
list( $native, $routed ) = tmdo_live_router_native_and_routed( $args );
|
||||
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
|
||||
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
|
||||
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request );
|
||||
tmdo_live_assert_true( false === stripos( $routed->request, 'GROUP BY' ), 'no GROUP BY: ' . $routed->request );
|
||||
}
|
||||
);
|
||||
|
||||
TMDO_Live::test(
|
||||
'a flat-first search keeps the GROUP BY when a tax query joins term relationships (2.2 PR-079)',
|
||||
static function () {
|
||||
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
|
||||
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||
TMDO_Post_Query_Router::forget_coverage();
|
||||
$terms = get_terms( array( 'taxonomy' => 'hp_listing_category', 'number' => 2, 'hide_empty' => true, 'fields' => 'ids' ) );
|
||||
if ( count( (array) $terms ) < 2 ) {
|
||||
throw new TMDO_Live_Skip( 'needs two used hp_listing_category terms' );
|
||||
}
|
||||
$args = tmdo_live_router_rating_sort( 'DESC' ) + array(
|
||||
'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => $terms, 'operator' => 'IN' ) ),
|
||||
);
|
||||
list( $native, $routed ) = tmdo_live_router_native_and_routed( $args );
|
||||
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
|
||||
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
|
||||
tmdo_live_assert_true( false !== stripos( $routed->request, 'GROUP BY' ), 'GROUP BY kept: ' . $routed->request );
|
||||
}
|
||||
);
|
||||
|
||||
@@ -7,7 +7,8 @@ $mode = TMDO_Mode_Manager::get( 'post' ) . '/' . TMDO_Mode_Manager::get( 'user'
|
||||
function nat( $t, $id, $k ) { global $wpdb; $mt = $t === 'user' ? $wpdb->usermeta : $wpdb->postmeta; $c = $t === 'user' ? 'user_id' : 'post_id'; $v = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM $mt WHERE $c=%d AND meta_key=%s", $id, $k ) ); return $v ? implode( '|', $v ) : '∅'; }
|
||||
function flat( $t, $g, $id, $k ) { global $wpdb; $c = $t === 'user' ? 'user_id' : 'post_id'; $v = $wpdb->get_row( $wpdb->prepare( "SELECT `$k` v FROM {$wpdb->prefix}wpdo_{$t}_{$g} WHERE $c=%d", $id ) ); return $v ? ( $v->v ?? 'NULL' ) : 'NO-ROW'; }
|
||||
function row( $name, $vals ) { printf( "%-44s %s\n", $name, implode( ' ', array_map( fn( $k, $v ) => "$k=" . ( is_scalar( $v ) || is_null( $v ) ? var_export( $v, true ) : json_encode( $v ) ), array_keys( $vals ), $vals ) ) ); }
|
||||
function clear_caches( $id ) { wp_cache_flush(); clean_post_cache( $id ); }
|
||||
// Never wp_cache_flush(): with Redis Object Cache selective flush it is one server-side scan that blocks Redis for every site.
|
||||
function clear_caches( $id ) { clean_post_cache( $id ); wp_cache_flush_runtime(); }
|
||||
echo "== mode post/user = $mode\n";
|
||||
|
||||
// T1: HivePress model update of a listing price.
|
||||
@@ -42,12 +43,12 @@ row( "T5 delete_post_meta hp_featured", array( 'native' => nat( 'post', $lid, 'h
|
||||
|
||||
// T6: user first_name via wp_update_user.
|
||||
$uid = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->users} WHERE user_login LIKE 's36v_B1p1_%' ORDER BY ID LIMIT 1" );
|
||||
$fn = 'Fn' . mt_rand( 100, 999 ); wp_update_user( array( 'ID' => $uid, 'first_name' => $fn ) ); wp_cache_flush();
|
||||
$fn = 'Fn' . mt_rand( 100, 999 ); wp_update_user( array( 'ID' => $uid, 'first_name' => $fn ) ); clean_user_cache( $uid ); wp_cache_flush_runtime();
|
||||
$u = get_userdata( $uid );
|
||||
row( "T6 user $uid first_name:=$fn", array( 'native' => nat( 'user', $uid, 'first_name' ), 'flat' => flat( 'user', 'core_profile', $uid, 'first_name' ), 'get_meta' => get_user_meta( $uid, 'first_name', true ), 'WP_User' => $u->first_name, 'wc_customer' => ( new WC_Customer( $uid ) )->get_first_name() ) );
|
||||
|
||||
// T7: WC customer billing city via CRUD.
|
||||
$c = new WC_Customer( $uid ); $city = 'City' . mt_rand( 10, 99 ); $c->set_billing_city( $city ); $c->save(); wp_cache_flush();
|
||||
$c = new WC_Customer( $uid ); $city = 'City' . mt_rand( 10, 99 ); $c->set_billing_city( $city ); $c->save(); clean_user_cache( $uid ); wp_cache_flush_runtime();
|
||||
row( "T7 WC_Customer billing_city:=$city", array( 'native' => nat( 'user', $uid, 'billing_city' ), 'flat' => flat( 'user', 'commerce', $uid, 'billing_city' ), 'get_meta' => get_user_meta( $uid, 'billing_city', true ), 'wc_customer' => ( new WC_Customer( $uid ) )->get_billing_city() ) );
|
||||
|
||||
// T8: delete a listing → flat row?
|
||||
@@ -57,5 +58,5 @@ row( "T8 wp_delete_post $d", array( 'posts' => (int) $wpdb->get_var( "SELECT COU
|
||||
|
||||
// T9: HP review → listing rating recompute.
|
||||
$before = get_post_meta( $lid, 'hp_rating_count', true );
|
||||
$rv = ( new Models\Review() )->fill( array( 'text' => 'mut', 'rating' => 5, 'approved' => 1, 'author' => $uid, 'author__display_name' => 'x', 'author__email' => 'x@example.test', 'listing' => $lid ) ); $rv->save(); wp_cache_flush();
|
||||
$rv = ( new Models\Review() )->fill( array( 'text' => 'mut', 'rating' => 5, 'approved' => 1, 'author' => $uid, 'author__display_name' => 'x', 'author__email' => 'x@example.test', 'listing' => $lid ) ); $rv->save(); clean_post_cache( $lid ); wp_cache_flush_runtime();
|
||||
row( "T9 add review (count was $before)", array( 'native' => nat( 'post', $lid, 'hp_rating_count' ), 'flat' => flat( 'post', 'hp_listing_core', $lid, 'hp_rating_count' ), 'hp_model' => Models\Listing::query()->get_by_id( $lid )->get_rating_count() ) );
|
||||
|
||||
@@ -40,7 +40,12 @@ foreach ( $cases as $name => $qa ) {
|
||||
$err = '';
|
||||
$sql = '';
|
||||
for ( $i = 0; $i < $iters; $i++ ) {
|
||||
wp_cache_flush_group( 'tmdo_found_rows' );
|
||||
// Fresh routed counts without wp_cache_flush_group() (a Redis-blocking server-side scan).
|
||||
if ( method_exists( 'TMDO_Post_Query_Router', 'bump_count_version' ) ) {
|
||||
TMDO_Post_Query_Router::bump_count_version();
|
||||
} else {
|
||||
wp_cache_set_last_changed( 'posts' ); // Core < 1.3.0 keyed the counts on posts' last_changed.
|
||||
}
|
||||
$wpdb->last_error = '';
|
||||
$t = microtime( true );
|
||||
$q = new WP_Query( $qa + array( 'fields' => 'ids', 'cache_results' => false, 'suppress_filters' => false ) );
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The admin settings form saves bridge modes through TMDO_Mode_Manager::set().
|
||||
* A refused change (unsafe jump, Promotion Guard) used to be dropped silently:
|
||||
* the page said "saved" while the mode stayed put (2.4.2 PR-135, manual F-2).
|
||||
*/
|
||||
class AdminBridgeModeSaveTest extends TestCase {
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
require_once TMDO_PATH . 'admin/class-tmdo-admin.php';
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
ini_set( 'error_log', '/dev/null' );
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
$GLOBALS['_wp_filter_callbacks'] = array();
|
||||
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
|
||||
$GLOBALS['_wp_current_user_id'] = 7;
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
|
||||
private function modes( array $modes ): void {
|
||||
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes;
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
|
||||
public function test_an_accepted_change_is_applied_without_refusals(): void {
|
||||
$this->modes( array( 'user' => 'disabled' ) );
|
||||
|
||||
$refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'dual_write' ) );
|
||||
|
||||
$this->assertSame( array(), $refused );
|
||||
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) );
|
||||
}
|
||||
|
||||
public function test_an_unsafe_jump_is_reported_and_the_mode_kept(): void {
|
||||
$this->modes( array( 'post' => 'disabled' ) );
|
||||
|
||||
$refused = TMDO_Admin::apply_bridge_modes( array( 'post' => 'shadow_read' ) );
|
||||
|
||||
$this->assertCount( 1, $refused );
|
||||
$this->assertStringStartsWith( 'post', $refused[0] );
|
||||
$this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'post' ) );
|
||||
}
|
||||
|
||||
public function test_empty_or_unknown_modes_are_ignored(): void {
|
||||
$this->modes( array( 'term' => 'disabled' ) );
|
||||
|
||||
$this->assertSame( array(), TMDO_Admin::apply_bridge_modes( array( 'post' => '', 'term' => 'bogus' ) ) );
|
||||
$this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'term' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
public function test_a_promotion_refused_by_the_guard_is_reported(): void {
|
||||
eval( 'namespace HivePress; class Core {}' );
|
||||
$this->modes( array( 'user' => 'shadow_read' ) );
|
||||
|
||||
$refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'aeav_only' ) );
|
||||
|
||||
$this->assertCount( 1, $refused );
|
||||
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) );
|
||||
}
|
||||
|
||||
/** The full-page form redirects after saving: refusals wait in a per-user transient, shown once. */
|
||||
public function test_refusals_are_kept_for_the_next_page_view_only(): void {
|
||||
TMDO_Admin::remember_bridge_mode_refusals( array( 'post:refused' ) );
|
||||
|
||||
$this->assertSame( array( 'post:refused' ), TMDO_Admin::pull_bridge_mode_refusals() );
|
||||
$this->assertSame( array(), TMDO_Admin::pull_bridge_mode_refusals() );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* TMDO_Auto_Promoter promotes shadow_read → aeav_only only when the last drift
|
||||
* check of every group of the entity found nothing, within the last two days
|
||||
* (2.2 PR-075; decision 2026-09-27). No record means no promotion.
|
||||
*/
|
||||
class AutoPromoterDriftTest extends TestCase {
|
||||
|
||||
private const TEN_DAYS_AGO = 864000;
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
ini_set( 'error_log', '/dev/null' );
|
||||
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'shadow_read' ) );
|
||||
$GLOBALS['_wp_filter_callbacks'] = array();
|
||||
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
TMDO_Entity_Registry::init();
|
||||
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
|
||||
TMDO_Entity_Registry::register_group( 'user', 'ap_one', array( array( 'key' => 'ap_a', 'type' => 'text' ) ) );
|
||||
TMDO_Entity_Registry::register_group( 'user', 'ap_two', array( array( 'key' => 'ap_b', 'type' => 'text' ) ) );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
TMDO_Entity_Registry::init();
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
|
||||
private function drift( string $group, int $diff, int $age = 0 ): void {
|
||||
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ]['user'][ $group ] = array(
|
||||
'miss' => 0,
|
||||
'diff' => $diff,
|
||||
'extra' => 0,
|
||||
'sample' => TMDO_Drift_Check::CRON_SAMPLE,
|
||||
'checked_at' => gmdate( 'Y-m-d H:i:s', time() - $age ),
|
||||
);
|
||||
}
|
||||
|
||||
private function check(): string {
|
||||
return TMDO_Auto_Promoter::check_entity( 'user', 7, array( 'user' => time() - self::TEN_DAYS_AGO ) );
|
||||
}
|
||||
|
||||
public function test_promotes_when_every_group_checked_clean_recently(): void {
|
||||
$this->drift( 'ap_one', 0 );
|
||||
$this->drift( 'ap_two', 0 );
|
||||
|
||||
$this->assertSame( 'promoted', $this->check() );
|
||||
$this->assertSame( 'aeav_only', TMDO_Mode_Manager::get( 'user' ) );
|
||||
}
|
||||
|
||||
public function test_drift_in_one_group_blocks_promotion(): void {
|
||||
$this->drift( 'ap_one', 0 );
|
||||
$this->drift( 'ap_two', 1 );
|
||||
|
||||
$this->assertSame( 'drift_not_clean', $this->check() );
|
||||
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) );
|
||||
}
|
||||
|
||||
public function test_a_group_never_checked_blocks_promotion(): void {
|
||||
$this->drift( 'ap_one', 0 );
|
||||
|
||||
$this->assertSame( 'drift_not_clean', $this->check() );
|
||||
}
|
||||
|
||||
public function test_a_check_older_than_two_days_blocks_promotion(): void {
|
||||
$this->drift( 'ap_one', 0 );
|
||||
$this->drift( 'ap_two', 0, 3 * DAY_IN_SECONDS );
|
||||
|
||||
$this->assertSame( 'drift_not_clean', $this->check() );
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,25 @@ class CacheLayerTest extends TestCase {
|
||||
$this->assertNotSame( [ 'stale' => true ], $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ?? null );
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushing bumps the post type's cache version instead of asking the
|
||||
* object cache to scan the group (a server-side script on Redis that
|
||||
* blocks it on a large group); older entries expire by their TTL.
|
||||
*/
|
||||
public function test_flush_group_moves_to_new_keys_without_a_group_flush(): void {
|
||||
$group = 'wpdo_cold_hp_listing';
|
||||
$old_key = WPDO_Zone_Cold::cache_key( 1, 'hp_listing' );
|
||||
$GLOBALS['_wp_cache'][ $group ][ $old_key ] = array( 'stale' => true );
|
||||
|
||||
WPDO_Cache_Layer::flush_group( 'hp_listing' );
|
||||
|
||||
$new_key = WPDO_Zone_Cold::cache_key( 1, 'hp_listing' );
|
||||
$this->assertNotSame( $old_key, $new_key );
|
||||
$this->assertFalse( wp_cache_get( $new_key, $group ) );
|
||||
$this->assertSame( 'cold_2', WPDO_Zone_Cold::cache_key( 2, 'other_type' ), 'other post types keep their keys' );
|
||||
$this->assertArrayHasKey( $old_key, $GLOBALS['_wp_cache'][ $group ], 'nothing scanned or deleted' );
|
||||
}
|
||||
|
||||
// ── get_stats() ──────────────────────────────────────────────────────────
|
||||
|
||||
public function test_get_stats_includes_required_keys(): void {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* `wp tmdo bridge-mode <entity> <mode>` (2.2 PR-069): the CLI way to change an
|
||||
* entity's bridge mode. It must go through TMDO_Mode_Manager::set(), so unsafe
|
||||
* jumps and promotions refused by the Promotion Guard never reach the option.
|
||||
* (The Guard matrix itself is covered by PromotionGuardTest.)
|
||||
*/
|
||||
class CliBridgeModeTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
ini_set( 'error_log', '/dev/null' );
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
$GLOBALS['_wp_filter_callbacks'] = array();
|
||||
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
|
||||
private function modes( array $modes ): void {
|
||||
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes;
|
||||
TMDO_Mode_Manager::reset_cache();
|
||||
}
|
||||
|
||||
public function test_sets_the_mode_of_one_entity(): void {
|
||||
$this->modes( array( 'user' => 'disabled', 'post' => 'dual_write' ) );
|
||||
|
||||
( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'dual_write' ), array() );
|
||||
|
||||
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) );
|
||||
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'post' ), 'other entities untouched' );
|
||||
}
|
||||
|
||||
public function test_an_unsafe_jump_is_refused(): void {
|
||||
$this->modes( array( 'user' => 'disabled' ) );
|
||||
|
||||
( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'aeav_only' ), array() );
|
||||
|
||||
$this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'user' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
public function test_a_promotion_refused_by_the_guard_leaves_the_mode_unchanged(): void {
|
||||
eval( 'namespace HivePress; class Core {}' );
|
||||
$this->modes( array( 'user' => 'shadow_read' ) );
|
||||
|
||||
( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'aeav_only' ), array() );
|
||||
|
||||
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) );
|
||||
$this->assertSame( array( 'user' => 'shadow_read' ), $GLOBALS['_wp_options']['wpdo_bridge_modes'] );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* `wp tmdo term-comment-diagnose` filter chain status (2.2 PR-076).
|
||||
*
|
||||
* It read each filter's option with a default of '1', while the garbage filter
|
||||
* and misc bucket default to off: a site that never touched them was told they
|
||||
* were enabled. The status now comes from each filter's own is_enabled().
|
||||
*/
|
||||
class CliFilterStatusTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
$GLOBALS['_wp_options'] = array();
|
||||
$GLOBALS['_wp_cli_output'] = array();
|
||||
}
|
||||
|
||||
private function filter_line( string $class, string $label ): string {
|
||||
$method = new ReflectionMethod( TMDO_CLI_Term_Comment::class, 'print_filter_status' );
|
||||
$method->setAccessible( true );
|
||||
$method->invoke( null, $class, $label );
|
||||
return (string) end( $GLOBALS['_wp_cli_output'] );
|
||||
}
|
||||
|
||||
public function test_filters_off_by_default_show_disabled(): void {
|
||||
$this->assertStringContainsString( 'disabled', $this->filter_line( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter' ) );
|
||||
$this->assertStringContainsString( 'disabled', $this->filter_line( 'TMDO_Term_Comment_Misc_Bucket', 'Misc bucket' ) );
|
||||
}
|
||||
|
||||
public function test_an_enabled_filter_shows_enabled(): void {
|
||||
$GLOBALS['_wp_options'][ TMDO_Term_Comment_Garbage_Filter::OPT_ENABLED ] = '1';
|
||||
|
||||
$this->assertStringContainsString( '✓ enabled', $this->filter_line( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter' ) );
|
||||
}
|
||||
|
||||
public function test_a_filter_whose_add_on_is_not_active_says_so(): void {
|
||||
$this->assertStringContainsString( 'not installed', $this->filter_line( 'TMDO_No_Such_Filter', 'WC term count filter' ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Backfill batches are scheduled per (entity, group) — REST backfill and
|
||||
* TMDO_Core re-chain `wpdo_entity_backfill_batch` with both args. The health
|
||||
* panel looked for (entity) alone, never found a running backfill and kept
|
||||
* offering to start one; starting one resets the group's checkpoint
|
||||
* (2.4.2 PR-137, manual F-11).
|
||||
*/
|
||||
class EntityHealthBackfillTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
$GLOBALS['_wp_next_scheduled'] = array();
|
||||
TMDO_Entity_Registry::init();
|
||||
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
|
||||
TMDO_Entity_Registry::register_group( 'user', 'eh_one', array( array( 'key' => 'eh_a', 'type' => 'text' ) ) );
|
||||
TMDO_Entity_Registry::register_group( 'user', 'eh_two', array( array( 'key' => 'eh_b', 'type' => 'text' ) ) );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$GLOBALS['_wp_next_scheduled'] = array();
|
||||
TMDO_Entity_Registry::init();
|
||||
}
|
||||
|
||||
private function backfill_active( string $entity_type ): bool {
|
||||
$m = new ReflectionMethod( TMDO_Entity_Health::class, 'is_backfill_active' );
|
||||
$m->setAccessible( true );
|
||||
return (bool) $m->invoke( null, $entity_type );
|
||||
}
|
||||
|
||||
public function test_a_backfill_scheduled_for_one_group_is_seen(): void {
|
||||
$GLOBALS['_wp_next_scheduled']['wpdo_entity_backfill_batch'][ md5( serialize( array( 'user', 'eh_two' ) ) ) ] = time() + 5;
|
||||
|
||||
$this->assertTrue( $this->backfill_active( 'user' ) );
|
||||
$this->assertFalse( $this->backfill_active( 'post' ), 'other entities are not affected' );
|
||||
}
|
||||
|
||||
public function test_no_scheduled_batch_means_no_backfill(): void {
|
||||
$this->assertFalse( $this->backfill_active( 'user' ) );
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ class EntityRegistryIndexesTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
TMDO_Entity_Registry::init();
|
||||
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||
$adapter = $this->createMock( TMDO_Entity_Adapter_Interface::class );
|
||||
$adapter->method( 'get_entity_id_column' )->willReturn( 'post_id' );
|
||||
TMDO_Entity_Registry::register_adapter( 'post', $adapter );
|
||||
}
|
||||
|
||||
private function fields(): array {
|
||||
@@ -37,6 +39,16 @@ class EntityRegistryIndexesTest extends TestCase {
|
||||
$this->assertSame( array( 'ok' => array( 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* A sort index ends with the entity ID, so a flat-first search ordered by a
|
||||
* column with the ID tie-break reads the index in order (2.4 PR-118).
|
||||
*/
|
||||
public function test_an_index_may_end_with_the_entity_id_column(): void {
|
||||
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'price_id' => array( 'x_price', 'post_id' ), 'id_first' => array( 'post_id', 'x_price' ) ) ) );
|
||||
|
||||
$this->assertSame( array( 'price_id' => array( 'x_price', 'post_id' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ), 'the ID only as the last column' );
|
||||
}
|
||||
|
||||
public function test_indexes_leave_the_field_definition_and_hash_alone(): void {
|
||||
TMDO_Entity_Registry::register_group( 'post', 'plain', $this->fields() );
|
||||
TMDO_Entity_Registry::register_group( 'post', 'indexed', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) );
|
||||
|
||||
@@ -130,4 +130,41 @@ class LoggerTest extends TestCase {
|
||||
$deleted = WPDO_Logger::purge( 30 );
|
||||
$this->assertSame( 5, $deleted );
|
||||
}
|
||||
|
||||
/** error_log() output of $fn. */
|
||||
private function logged( callable $fn ): string {
|
||||
$file = tempnam( sys_get_temp_dir(), 'tmdo-log' );
|
||||
$old = ini_set( 'error_log', $file );
|
||||
try {
|
||||
$fn();
|
||||
} finally {
|
||||
ini_set( 'error_log', (string) $old );
|
||||
}
|
||||
$out = (string) file_get_contents( $file );
|
||||
unlink( $file );
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function test_info_stays_out_of_the_log_unless_debug_logging_is_on(): void {
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
|
||||
|
||||
$this->assertSame( '', $this->logged( static fn() => WPDO_Logger::info( 'cache_flushed' ) ) );
|
||||
|
||||
add_filter( 'tmdo_log_info', static fn() => true );
|
||||
try {
|
||||
$this->assertStringContainsString( '[WPDO][INFO][cache_flushed]', $this->logged( static fn() => WPDO_Logger::info( 'cache_flushed' ) ) );
|
||||
} finally {
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
|
||||
}
|
||||
}
|
||||
|
||||
public function test_notices_are_always_logged_for_the_audit_trail(): void {
|
||||
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
|
||||
|
||||
$this->assertStringContainsString( '[WPDO][NOTICE][bridge_mode_changed]', $this->logged( static fn() => WPDO_Logger::notice( 'bridge_mode_changed', array( 'to' => 'dual_write' ) ) ) );
|
||||
}
|
||||
|
||||
public function test_warnings_are_always_logged(): void {
|
||||
$this->assertStringContainsString( '[WPDO][WARN][odd_return]', $this->logged( static fn() => WPDO_Logger::warning( 'odd_return' ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,13 +33,12 @@ class PostFieldsRegistrationTest extends TestCase {
|
||||
$this->assertTrue( true );
|
||||
}
|
||||
|
||||
public function test_all_four_groups_are_registered(): void {
|
||||
public function test_the_core_groups_are_registered(): void {
|
||||
WPDO_Post_Fields::register_entity_fields();
|
||||
|
||||
$expected = array(
|
||||
'wp_core',
|
||||
'attachment',
|
||||
'wc_product',
|
||||
'nav_menu_item',
|
||||
);
|
||||
|
||||
@@ -92,38 +91,14 @@ class PostFieldsRegistrationTest extends TestCase {
|
||||
$this->assertSame( 'json', $field['type'] );
|
||||
}
|
||||
|
||||
// ── wc_product group ─────────────────────────────────────────────────────
|
||||
// ── WooCommerce products stay in native meta (2.2 PR-077) ───────────────
|
||||
|
||||
public function test_wc_product_group_has_19_keys(): void {
|
||||
public function test_core_registers_no_woocommerce_product_group(): void {
|
||||
WPDO_Post_Fields::register_entity_fields();
|
||||
$keys = $this->get_field_keys( 'wc_product' );
|
||||
$this->assertCount( 19, $keys, 'wc_product group should register exactly 19 keys.' );
|
||||
}
|
||||
|
||||
public function test_wc_product_critical_keys_present(): void {
|
||||
WPDO_Post_Fields::register_entity_fields();
|
||||
$keys = $this->get_field_keys( 'wc_product' );
|
||||
foreach ( array( '_price', '_regular_price', '_sale_price', '_stock', '_stock_status', '_sku' ) as $key ) {
|
||||
$this->assertContains( $key, $keys, "wc_product missing critical key: {$key}" );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_wc_product_price_is_searchable_decimal(): void {
|
||||
WPDO_Post_Fields::register_entity_fields();
|
||||
$field = $this->find_field( 'wc_product', '_price' );
|
||||
$this->assertNotNull( $field );
|
||||
$this->assertSame( 'decimal', $field['type'] );
|
||||
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
|
||||
}
|
||||
|
||||
public function test_wc_product_stock_status_is_enum_searchable(): void {
|
||||
WPDO_Post_Fields::register_entity_fields();
|
||||
$field = $this->find_field( 'wc_product', '_stock_status' );
|
||||
$this->assertNotNull( $field );
|
||||
$this->assertSame( 'enum', $field['type'] );
|
||||
$this->assertTrue( (bool) ( $field['searchable'] ?? false ) );
|
||||
$this->assertContains( 'instock', $field['options'] );
|
||||
$this->assertContains( 'outofstock', $field['options'] );
|
||||
$this->assertSame( array(), WPDO_Entity_Registry::get_group_fields( 'post', 'wc_product' ), 'WooCommerce reads and writes product meta directly (stock by SQL)' );
|
||||
$this->assertNull( WPDO_Entity_Registry::get_field( 'post', '_price', 'product' ) );
|
||||
$this->assertSame( array(), WPDO_Post_Fields::groups_for_post_type( 'product' ) );
|
||||
}
|
||||
|
||||
// ── HivePress groups moved to the HivePress AddOn ───────────────────────
|
||||
@@ -139,7 +114,6 @@ class PostFieldsRegistrationTest extends TestCase {
|
||||
|
||||
public function test_core_groups_map_to_their_post_types(): void {
|
||||
$this->assertSame( 'attachment', WPDO_Post_Fields::post_type_for_group( 'attachment' ) );
|
||||
$this->assertSame( 'product', WPDO_Post_Fields::post_type_for_group( 'wc_product' ) );
|
||||
$this->assertSame( 'nav_menu_item', WPDO_Post_Fields::post_type_for_group( 'nav_menu_item' ) );
|
||||
$this->assertNull( WPDO_Post_Fields::post_type_for_group( 'wp_core' ), 'wp_core spans every post type' );
|
||||
}
|
||||
@@ -149,7 +123,6 @@ class PostFieldsRegistrationTest extends TestCase {
|
||||
|
||||
$this->assertSame( 'x_type', WPDO_Post_Fields::post_type_for_group( 'x_core' ) );
|
||||
$this->assertSame( array( 'x_core' ), WPDO_Post_Fields::groups_for_post_type( 'x_type' ) );
|
||||
$this->assertSame( array( 'wc_product' ), WPDO_Post_Fields::groups_for_post_type( 'product' ) );
|
||||
$this->assertNull( WPDO_Post_Fields::post_type_for_group( 'hp_listing_core' ), 'unregistered HivePress group is unknown to core' );
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,21 @@ class RestApiTest extends TestCase {
|
||||
$this->assertSame( array( 'id', 'post_type', 'hp_price' ), array_keys( $data ) );
|
||||
}
|
||||
|
||||
public function test_contact_and_address_keys_stay_private_even_when_declared_public(): void {
|
||||
$private = array( 'hp_email', 'hp_contact_e_mail', 'hp_phone_number', 'hp_address', 'billing_address_1', 'hp_ip', 'hp_client_ip_addr' );
|
||||
$public = array( 'hp_price', 'hp_description', 'hp_tip_amount', 'hp_shipping_days' );
|
||||
$fields = array();
|
||||
$values = array();
|
||||
foreach ( array_merge( $private, $public ) as $key ) {
|
||||
$fields[] = array( 'meta_key' => $key, 'zone' => 'cold', 'show_in_rest' => true );
|
||||
$values[ $key ] = 'v';
|
||||
}
|
||||
|
||||
$data = $this->listing_with( $fields, $values );
|
||||
|
||||
$this->assertSame( array_merge( array( 'id', 'post_type' ), $public ), array_keys( $data ) );
|
||||
}
|
||||
|
||||
public function test_rest_visible_columns_exclude_protected_and_credential_keys(): void {
|
||||
$registry = WPDO_Schema_Registry::instance();
|
||||
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) );
|
||||
@@ -377,7 +392,11 @@ class RestApiTest extends TestCase {
|
||||
$this->assertSame( 'too_many_requests', $response->get_data()['code'] );
|
||||
}
|
||||
|
||||
public function test_post_view_429_increments_rate_limit_stats(): void {
|
||||
/**
|
||||
* Blocked requests are counted 1 in 10 (each sample adds 10), so anonymous
|
||||
* traffic cannot turn every blocked request into an option write.
|
||||
*/
|
||||
public function test_post_view_429_samples_rate_limit_stats(): void {
|
||||
$GLOBALS['_wp_post_types'][40] = 'hp_listing';
|
||||
$_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block.
|
||||
$nonce = wp_create_nonce( 'wp_rest' );
|
||||
@@ -385,10 +404,17 @@ class RestApiTest extends TestCase {
|
||||
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' );
|
||||
$req->set_param( 'id', 40 );
|
||||
$req->set_header( 'X-WP-Nonce', $nonce );
|
||||
$this->api->post_view( $req );
|
||||
$writes = 0;
|
||||
for ( $i = 0; $i < 200; $i++ ) {
|
||||
$before = get_option( 'wpdo_rl_stats', [] );
|
||||
$this->assertSame( 429, $this->api->post_view( $req )->get_status() );
|
||||
$writes += get_option( 'wpdo_rl_stats', [] ) === $before ? 0 : 1;
|
||||
}
|
||||
|
||||
$stats = get_option( 'wpdo_rl_stats', [] );
|
||||
$this->assertSame( 1, (int) ( $stats['40'] ?? 0 ) );
|
||||
$count = (int) ( get_option( 'wpdo_rl_stats', [] )['40'] ?? 0 );
|
||||
$this->assertSame( 10 * $writes, $count, 'each sample adds 10' );
|
||||
$this->assertGreaterThan( 0, $writes );
|
||||
$this->assertLessThan( 60, $writes, 'about 20 writes for 200 blocked requests, not 200' );
|
||||
}
|
||||
|
||||
// ── get_status: rate_limit_stats ─────────────────────────────────────────
|
||||
|
||||
+16
-16
@@ -30,7 +30,7 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
||||
global $wpdb;
|
||||
|
||||
// ── Load shared cleanup logic + snapshot classes ─────────────────────────
|
||||
$bootstrap_files = array(
|
||||
$wpdo_bootstrap_files = array(
|
||||
__DIR__ . '/includes/class-tmdo-logger.php',
|
||||
__DIR__ . '/includes/class-tmdo-feature-flags.php',
|
||||
__DIR__ . '/includes/class-tmdo-installer.php',
|
||||
@@ -39,13 +39,13 @@ $bootstrap_files = array(
|
||||
__DIR__ . '/includes/snapshots/class-tmdo-snapshot-reader.php',
|
||||
__DIR__ . '/includes/snapshots/class-tmdo-snapshot-pruner.php',
|
||||
);
|
||||
$bootstrap_ok = true;
|
||||
foreach ( $bootstrap_files as $f ) {
|
||||
if ( ! is_file( $f ) ) {
|
||||
$bootstrap_ok = false;
|
||||
$wpdo_bootstrap_ok = true;
|
||||
foreach ( $wpdo_bootstrap_files as $wpdo_bootstrap_file ) {
|
||||
if ( ! is_file( $wpdo_bootstrap_file ) ) {
|
||||
$wpdo_bootstrap_ok = false;
|
||||
continue;
|
||||
}
|
||||
require_once $f;
|
||||
require_once $wpdo_bootstrap_file;
|
||||
}
|
||||
|
||||
// ── Sites to check and clean (0 = single site, no blog switching) ────────
|
||||
@@ -77,7 +77,7 @@ $wpdo_for_each_site = static function ( callable $callback ) use ( $wpdo_site_id
|
||||
};
|
||||
|
||||
// ── Refuse while any site holds data that exists only in wpdo_* tables ───
|
||||
if ( ! $bootstrap_ok ) {
|
||||
if ( ! $wpdo_bootstrap_ok ) {
|
||||
wp_die( esc_html( '2meet Data Optimizer:無法載入資料安全檢查,為避免資料遺失,已停止解除安裝。' ) );
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ if ( $wpdo_blocked ) {
|
||||
wp_die(
|
||||
'<p>' . esc_html( '2meet Data Optimizer 已停止解除安裝:以下資料只存在 wpdo_* 表,刪除外掛會一併刪除這些表。' ) . '</p>'
|
||||
. '<ul><li>' . implode( '</li><li>', array_map( 'esc_html', $wpdo_blocked ) ) . '</li></ul>'
|
||||
. '<p>' . esc_html( '目前沒有把這些資料寫回 WordPress 原生表的自動工具。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '</p>'
|
||||
. '<p>' . esc_html( '請先以 wp tmdo bridge-restore-native 把資料寫回 WordPress 原生表,再解除安裝。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '</p>'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ if ( $wpdo_blocked ) {
|
||||
// wpdo_snapshots rows (which is per-site so per-site snapshots survive the
|
||||
// site-level table DROP that happens later). Best-effort.
|
||||
if ( class_exists( 'TMDO_Snapshot_Manager' ) ) {
|
||||
$result = TMDO_Snapshot_Manager::create(
|
||||
$wpdo_snapshot = TMDO_Snapshot_Manager::create(
|
||||
'pre_uninstall',
|
||||
array(),
|
||||
array(
|
||||
@@ -112,18 +112,18 @@ if ( class_exists( 'TMDO_Snapshot_Manager' ) ) {
|
||||
'inline_threshold_bytes' => 0,
|
||||
)
|
||||
);
|
||||
if ( ! empty( $result['ok'] ) ) {
|
||||
$snapshot_id = $result['snapshot_id'];
|
||||
$dir = TMDO_Snapshot_Manager::backup_dir();
|
||||
if ( is_dir( $dir ) ) {
|
||||
if ( ! empty( $wpdo_snapshot['ok'] ) ) {
|
||||
$wpdo_snapshot_id = $wpdo_snapshot['snapshot_id'];
|
||||
$wpdo_backup_dir = TMDO_Snapshot_Manager::backup_dir();
|
||||
if ( is_dir( $wpdo_backup_dir ) ) {
|
||||
file_put_contents(
|
||||
trailingslashit( $dir ) . 'README-pre-uninstall.txt',
|
||||
"WPDO pre-uninstall snapshot: {$snapshot_id}\n"
|
||||
trailingslashit( $wpdo_backup_dir ) . 'README-pre-uninstall.txt',
|
||||
"WPDO pre-uninstall snapshot: {$wpdo_snapshot_id}\n"
|
||||
. 'Created: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n\n"
|
||||
. "To restore after re-installing wp-data-optimizer:\n"
|
||||
. " 1. Re-install the plugin (it will re-create wp_wpdo_snapshots).\n"
|
||||
. " 2. Manually re-insert this snapshot's catalog row, or use wp wpdo snapshot list to confirm.\n"
|
||||
. " 3. Run: wp wpdo snapshot restore {$snapshot_id} --apply\n"
|
||||
. " 3. Run: wp wpdo snapshot restore {$wpdo_snapshot_id} --apply\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user