Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 336664473b | |||
| abbdb39acc | |||
| e8731c5119 | |||
| dc9a8e9886 | |||
| a7134bfb80 | |||
| 4fc59b757e | |||
| aa2da274dd | |||
| ec75721976 | |||
| f2e261c522 | |||
| b338925605 | |||
| 8c049f0cf2 | |||
| 77e338f9c3 |
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: 2meet Data Optimizer
|
* Plugin Name: 2meet Data Optimizer
|
||||||
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
||||||
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
||||||
* Version: 1.0.2
|
* Version: 1.1.1
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Tested up to: 6.9.4
|
* Tested up to: 6.9.4
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
define( 'TMDO_VERSION', '1.0.2' );
|
define( 'TMDO_VERSION', '1.1.1' );
|
||||||
define( 'TMDO_DB_VERSION', '2.1.0' );
|
define( 'TMDO_DB_VERSION', '2.1.0' );
|
||||||
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|||||||
@@ -7,6 +7,82 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.1.1] — 2026-09-26 — Security:公開 REST 不再回傳私有欄位
|
||||||
|
|
||||||
|
**性質**:安全修正(Critical)。1.0.x 起即存在;1.1.0 zip 已撤回(`dist/withdrawn/`)。
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **公開的 `GET /wpdo/v1/listings`、`/wpdo/v1/listings/{id}` 會把所有已註冊的 hot / cold
|
||||||
|
欄位回傳給未登入者**:`TMDO_Schema_Registry::register()` 的 `show_in_rest` 預設為 true,
|
||||||
|
而 addon 都沒有設定它。dev36 實測可取得 infocards 的 `tmeetic_ig_access_token_enc`、
|
||||||
|
`tmeetic_ig_token_expiry`、`tmeetic_ig_user_id`,以及 WooCommerce 的 `_purchase_note`。
|
||||||
|
- `show_in_rest` 預設改為 **false**:要公開的欄位必須明確宣告。
|
||||||
|
- 受保護的 meta key(底線開頭)與名稱像憑證的 key(token、secret、password、api key、
|
||||||
|
private key、credential、salt、nonce)即使宣告公開也一律不輸出
|
||||||
|
(`TMDO_Schema_Registry::rest_exposable()`)。
|
||||||
|
- Zone A 列表路徑在沒有任何公開欄位時不再回傳整列(fail-closed);篩選參數與 orderby
|
||||||
|
只接受公開欄位,避免藉由篩選 / 排序推測隱藏值。
|
||||||
|
- **相容性**:依賴預設公開的欄位,現在需在註冊時加上 `'show_in_rest' => true`,或使用
|
||||||
|
既有的 `tmdo_rest_listing_visible_fields` filter。
|
||||||
|
- 測試:`RestApiTest` 新增 3 個;live suite `rest`(未登入 GET 不得出現私有 / 憑證類 key)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0] — 2026-09-26 — HivePress / WooCommerce 搜尋索引模式(hardening 2.1)
|
||||||
|
|
||||||
|
**性質**:資料正確性修正 + 查詢改寫補強。無 schema 變更。決策與設計見
|
||||||
|
`docs/adr-004-search-index-mode-for-hp-wc.md`;搭配 HivePress AddOn 0.2.0。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Promotion Guard**(`TMDO_Promotion_Guard`):HivePress 啟用時 post / user / term /
|
||||||
|
comment、WooCommerce 啟用時 post / user 不得升到 aeav_only 或執行 cleanup
|
||||||
|
(`WP_Error( 'promotion_guarded' )`,filter `tmdo_promotion_guard_allow`);已在
|
||||||
|
aeav_only 的站只顯示後台 notice。(PR-053)
|
||||||
|
- **`wp tmdo repair-array-literals [--entity] [--confirm]`**:把扁平表中的 `"Array"`
|
||||||
|
從原生修回;原生也被污染的列出清單。(PR-055)
|
||||||
|
- **`bridge-restore-native --overwrite / --only-missing`**:預設依模式(aeav_only 覆寫、
|
||||||
|
其他只補缺);報告新增 `missing` / `differs` / `same` / `default_skipped`。(PR-058)
|
||||||
|
- **live 測試層** `tests/live/`(在真實站台以 `wp eval-file` 執行):suites
|
||||||
|
`shadow_write`、`serialized`、`resync`、`router`、`zero_side_effect`,以及 qbench /
|
||||||
|
fe.sh 等量測工具。(PR-052)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Hook Bus 寫入順序**:dual_write / shadow_read 由 WordPress 寫原生,扁平表在
|
||||||
|
`added_ / updated_ / deleted_{type}_meta` 從原生重新同步(含 `delete_all`、
|
||||||
|
`update_metadata_by_mid` 改 key)。(PR-057)
|
||||||
|
- **shadow_read** 讀原生,並抽樣比對扁平值(`tmdo_shadow_compare_sample_rate`)。(PR-056)
|
||||||
|
- **Post query router**:
|
||||||
|
- 全有或全無:OR、多 post type、含已註冊 key 的巢狀 group、type 語意與欄位不符的
|
||||||
|
clause 讓整個查詢維持原生;LIKE 包 `%esc_like%`;`pre_get_posts` priority 1000。(PR-060)
|
||||||
|
- 每個 group 各自 JOIN、不加 GROUP BY;NOT EXISTS 不走狀態 / 日期副本路由。(PR-061)
|
||||||
|
- meta orderby:HivePress `_sort`(NOT EXISTS + EXISTS)形狀、具名 clause、
|
||||||
|
`meta_key` + `meta_value_num`。(PR-062)
|
||||||
|
- routed 搜尋維持 WordPress 的 split query(ID-first)。(PR-062b)
|
||||||
|
- 依扁平欄位排序且有篩選的搜尋改由扁平表驅動(`STRAIGHT_JOIN`,posts.ID tie-break 改讀等值的
|
||||||
|
`post_id`),不再以 temporary table 排序;filter `tmdo_router_flat_first` 可關閉。(PR-062c)
|
||||||
|
- `composer phpcs` 改用 `phpcs.xml`(與 CI 相同)。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- shadow_read 下 `update_*_meta()` 回傳 false、原生沒有寫入(資料遺失)。(PR-056)
|
||||||
|
- 陣列 / 物件存進 text 欄位變成 `"Array"`;text 超過 255 字元改存 NULL 並記錄
|
||||||
|
`text_overflow`。(PR-055)
|
||||||
|
- `bridge-restore-native` 覆蓋較新的原生值(WC 庫存)、替每個 user 捏造預設值列。(PR-058)
|
||||||
|
- Cold / Hot 表不存在時,`save_post`、`loop_start`、刪除文章仍查詢它們(DB error)。(PR-054、PR-054b)
|
||||||
|
- 刪除 post / user / term / comment 後,扁平表留下全 NULL 的孤兒列(PR-057 的 NULL 同步會重建已刪的列)。(PR-057b)
|
||||||
|
- Router:Q05(wp_core 與另一 group 的 key 並用)`Unknown column`;OR / 多 post type /
|
||||||
|
無 type 數值比較 / 具名 orderby 的結果與原生不同。(PR-060〜062)
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- ADR-004、`CONTEXT.md` Entity Bridge(寫入順序、shadow_read、搜尋索引模式、Router 規則)、
|
||||||
|
`docs/BENCHMARK-SCALE.md` 前台與寫入鏈實測。(PR-064)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.0.2] — 2026-08-15 — NinjaFirewall 相容性:WAF 設定選項保護
|
## [1.0.2] — 2026-08-15 — NinjaFirewall 相容性:WAF 設定選項保護
|
||||||
|
|
||||||
**性質**:防禦性修正 + 文件。無 schema 變更,無破壞性變更。
|
**性質**:防禦性修正 + 文件。無 schema 變更,無破壞性變更。
|
||||||
|
|||||||
+23
@@ -135,6 +135,29 @@ Newer anti-EAV system (v2.5+) covering user, term, comment, and post entities vi
|
|||||||
The Entity Bridge and the Zone system coexist; the Sync Bridge's
|
The Entity Bridge and the Zone system coexist; the Sync Bridge's
|
||||||
`is_owned_by_entity_bridge()` guard prevents duplicate writes.
|
`is_owned_by_entity_bridge()` guard prevents duplicate writes.
|
||||||
|
|
||||||
|
**Write order (v1.1.0).** In `dual_write` / `shadow_read` the Hook Bus pre-filters
|
||||||
|
only decide the route and return `null`: WordPress writes native meta itself, and the
|
||||||
|
flat value is then re-synced *from native* in `added_/updated_/deleted_{type}_meta`
|
||||||
|
(priority 1) — `TMDO_Hook_Bus::sync_from_native()` reads the first native row with
|
||||||
|
direct SQL; no native row → flat NULL. `aeav_only` keeps the pre-filter short-circuit.
|
||||||
|
|
||||||
|
**shadow_read** reads native meta (WordPress meta cache) and compares a sample of reads
|
||||||
|
with the flat value (`tmdo_shadow_compare_sample_rate`, default 0.1). Flat tables serve
|
||||||
|
queries only: `TMDO_Post_Query_Router` is active from `shadow_read` up.
|
||||||
|
|
||||||
|
**Search-index mode** (ADR-004). On HivePress / WooCommerce sites native meta is the
|
||||||
|
source of truth and flat tables are its search index: `TMDO_Promotion_Guard` refuses
|
||||||
|
`aeav_only` and cleanup there (post/user/term/comment with HivePress, post/user with
|
||||||
|
WooCommerce), because both read meta with an empty key and WooCommerce updates stock
|
||||||
|
and sales with direct SQL. Override: filter `tmdo_promotion_guard_allow`.
|
||||||
|
|
||||||
|
**Post query router rules.** A query is rewritten only when the flat table returns
|
||||||
|
what native `meta_query` would: every registered clause routable (else the whole query
|
||||||
|
stays native), AND relation, one post type, column type matching the clause's CAST
|
||||||
|
semantics, LIKE as `%esc_like%`. Meta ordering (the HivePress `_sort` NOT EXISTS/EXISTS
|
||||||
|
shape, a named clause, `meta_key` + `meta_value_num`) is routed only as the sole
|
||||||
|
`orderby` on a column that orders like native.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Migration Phase (Strategy pattern)
|
## Migration Phase (Strategy pattern)
|
||||||
|
|||||||
@@ -162,7 +162,15 @@ class TMDO_CLI_V2 {
|
|||||||
* : post | user | term | comment
|
* : post | user | term | comment
|
||||||
*
|
*
|
||||||
* [--dry-run]
|
* [--dry-run]
|
||||||
* : Count the flat rows per group; write nothing.
|
* : Count the flat rows per group and how their values compare with native
|
||||||
|
* meta (missing / differs / same / default skipped); write nothing.
|
||||||
|
*
|
||||||
|
* [--overwrite]
|
||||||
|
* : Flat values replace differing native values. Default in aeav_only.
|
||||||
|
*
|
||||||
|
* [--only-missing]
|
||||||
|
* : Only fill native rows that do not exist; never replace. Default in every
|
||||||
|
* other mode, where native meta is the source of truth.
|
||||||
*
|
*
|
||||||
* [--batch=<n>]
|
* [--batch=<n>]
|
||||||
* : Rows per batch. Default 500.
|
* : Rows per batch. Default 500.
|
||||||
@@ -190,17 +198,34 @@ class TMDO_CLI_V2 {
|
|||||||
public function bridge_restore_native( $args, $assoc_args ): void {
|
public function bridge_restore_native( $args, $assoc_args ): void {
|
||||||
$entity = (string) ( $args[0] ?? '' );
|
$entity = (string) ( $args[0] ?? '' );
|
||||||
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
||||||
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--batch=<n>] [--demote] [--group=<group> [--retire]]' );
|
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--overwrite|--only-missing] [--batch=<n>] [--demote] [--group=<group> [--retire]]' );
|
||||||
}
|
}
|
||||||
$dry_run = isset( $assoc_args['dry-run'] );
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
$group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) );
|
$group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) );
|
||||||
if ( isset( $assoc_args['retire'] ) && '' === $group ) {
|
if ( isset( $assoc_args['retire'] ) && '' === $group ) {
|
||||||
WP_CLI::error( '--retire needs --group=<group>.' );
|
WP_CLI::error( '--retire needs --group=<group>.' );
|
||||||
}
|
}
|
||||||
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group );
|
if ( isset( $assoc_args['overwrite'] ) && isset( $assoc_args['only-missing'] ) ) {
|
||||||
|
WP_CLI::error( 'Use either --overwrite or --only-missing, not both.' );
|
||||||
|
}
|
||||||
|
$policy = isset( $assoc_args['overwrite'] ) ? 'overwrite' : ( isset( $assoc_args['only-missing'] ) ? 'only_missing' : '' );
|
||||||
|
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group, $policy );
|
||||||
|
|
||||||
|
WP_CLI::log( "Policy: {$report['policy']}" );
|
||||||
foreach ( $report['groups'] as $group => $g ) {
|
foreach ( $report['groups'] as $group => $g ) {
|
||||||
WP_CLI::log( sprintf( '%s/%s: %d row(s)%s', $entity, $group, $g['rows'], $dry_run ? '' : sprintf( ', %d native value(s) written', $g['written'] ) ) );
|
WP_CLI::log(
|
||||||
|
sprintf(
|
||||||
|
'%s/%s: %d row(s) — native missing %d, differs %d, same %d, default skipped %d%s',
|
||||||
|
$entity,
|
||||||
|
$group,
|
||||||
|
$g['rows'],
|
||||||
|
$g['missing'],
|
||||||
|
$g['differs'],
|
||||||
|
$g['same'],
|
||||||
|
$g['default_skipped'],
|
||||||
|
$dry_run ? '' : sprintf( '; %d native value(s) written', $g['written'] )
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if ( $dry_run ) {
|
if ( $dry_run ) {
|
||||||
WP_CLI::success( '[dry-run] Nothing written.' );
|
WP_CLI::success( '[dry-run] Nothing written.' );
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "phpunit --configuration phpunit.xml",
|
"test": "phpunit --configuration phpunit.xml",
|
||||||
"test:integration": "phpunit --configuration phpunit-integration.xml",
|
"test:integration": "phpunit --configuration phpunit-integration.xml",
|
||||||
"phpcs": "phpcs --standard=WordPress includes/ admin/ cli/ modules/ 2meet-data-optimizer.php uninstall.php",
|
"phpcs": "phpcs --standard=phpcs.xml",
|
||||||
"stan": "phpstan analyse --no-progress",
|
"stan": "phpstan analyse --no-progress",
|
||||||
"stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon"
|
"stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -63,6 +63,41 @@ SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個
|
|||||||
6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。
|
6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。
|
||||||
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
|
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
|
||||||
|
|
||||||
|
## 前台與寫入鏈實測(dev36,Release 2.1,2026-09-26)
|
||||||
|
|
||||||
|
上面的數字是純 SQL。這一節是真實站台(dev36:HivePress 1.7.31 + Marketplace、WooCommerce 11.1.2、Redis object cache、listing 約 10 萬筆)上的整頁與整條寫入鏈,決策背景見 `adr-004-search-index-mode-for-hp-wc.md`。
|
||||||
|
|
||||||
|
**前台搜尋**(`tests/live/tools/fe.sh`:每個 URL 5 次取中位數,`X-S36-Ms` = 整個請求到 `template_redirect`)。「routed」是 post 模式 shadow_read + 核心 2.1 router + HivePress AddOn 0.2.0;「1.0.x」是整合測試當時的 router(報告 `notes/tmdo-integration-test-dev36-2026-09-26.md`)。
|
||||||
|
|
||||||
|
| URL | 原生 ms | 1.0.x ms | 2.1 routed ms | ids 與原生相同 |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| listing 價格區間 | 614 | 496 | **323** | 是 |
|
||||||
|
| listing 價格區間 + `_sort=price__asc` | 975 | 1,062 | **435** | 是 |
|
||||||
|
| listing 時薪區間 + `_sort=hourly_rate__desc` | 850 | — | **367** | 是 |
|
||||||
|
| listing `_sort=rating__desc` | 1,697 | 1,720 | **866** | 是 |
|
||||||
|
| listing 關鍵字 + 價格區間 | 754 | — | 764 | 是 |
|
||||||
|
| vendor 時薪區間 | 102 | — | 97 | 是 |
|
||||||
|
| WC 商店價格篩選 / 價格排序 | 94 / 111 | 88–94 | 109 / 88 | 是(WC 用 lookup 表,不經 router) |
|
||||||
|
|
||||||
|
(R-2.1 驗收,扁平表已從原生回填、含 PR-062c;兩輪量測見 `notes/tmdo-acceptance-2.1-2026-09-26.md`。)
|
||||||
|
|
||||||
|
- HivePress 搜尋仍帶 tax_query(排除一個 term),WordPress 因此加上 `GROUP BY posts.ID`,排序需要 temporary + filesort;這是目前 311–428 ms 的主要成本,尚未達到 R-2.1 的 300 ms 目標。
|
||||||
|
- PR-062b 之前,routed 搜尋因 `posts_request` 改寫而失去 WordPress 的 split query,改撈整列 `posts.*`:同一條 SQL 撈 `*` 要 590 ms,只撈 ID 為 312 ms。
|
||||||
|
- PR-062c 之前,依扁平欄位排序的 routed 查詢由 `wp_posts`(type / status 索引,約 56k 列)驅動、逐筆查扁平表再以 temporary table 排序;拿掉 GROUP BY 或只換 tie-break 都無效(約 300 ms),改由扁平表驅動並把 tie-break 改讀 `post_id` 後只剩單表 filesort:`meta_value_num` 263→147 ms、具名排序 250→142 ms、HP 時薪篩選 + 排序 300→36 ms(只計 SQL)。依日期排序的搜尋由扁平表驅動反而變慢(74→137 ms),所以 flat-first 只用在扁平欄位排序。
|
||||||
|
- 量測 gotcha:symlink 切換(`plugins/` → `plugins-dev`)後,PHP-FPM 的 opcache(`revalidate_path=0`)仍執行舊目標的程式,WP-CLI 不受影響;前台數字要先確認實際載入的檔案。
|
||||||
|
|
||||||
|
**WP_Query**(`tests/live/tools/qbench.php`,`fields=ids`,20 筆一頁):價格區間 296→76 ms、價格 + verified 360→53 ms、rating ≥ 4.5 248→33 ms、rate + `_thumbnail_id` EXISTS 24→9 ms;OR、多 post type、無 type 數值比較等維持原生,結果與原生相同。
|
||||||
|
|
||||||
|
**寫入鏈開銷**(單一程序,disabled 與 dual_write 交替兩輪取平均):
|
||||||
|
|
||||||
|
| 寫入 | disabled | dual_write | 增加 |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| HP listing | 754 ms / 348 q | 976 ms / 434 q | +29% |
|
||||||
|
| WC product | 215 ms / 129 q | 489 ms / 206 q | +127% |
|
||||||
|
| vendor(user + post) | 444 ms / 162 q | 630 ms / 230 q | +42% |
|
||||||
|
|
||||||
|
WC product 的成本來自 `wc_product` 群組,Release 2.2 移除 WC 扁平化後消失。
|
||||||
|
|
||||||
## 優化候選(待使用者確認後排入 backlog)
|
## 優化候選(待使用者確認後排入 backlog)
|
||||||
|
|
||||||
| # | 候選 | 針對 | 預期效果 |
|
| # | 候選 | 針對 | 預期效果 |
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# ADR-004:HivePress / WooCommerce 站採「搜尋索引模式」
|
||||||
|
|
||||||
|
- **狀態**:已接受(2026-09-26)
|
||||||
|
- **日期**:2026-09-26
|
||||||
|
- **相關**:dev36 整合測試報告(`notes/tmdo-integration-test-dev36-2026-09-26.md`)、ADR-001、ADR-003、backlog PR-052〜PR-063(Release 2.1)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
2026-09-26 在 dev36(HivePress 1.7.31 + Marketplace + 10 個擴充、WooCommerce 11.1.2、Redis、10 萬筆 listing)做整合與壓力測試,確認:
|
||||||
|
|
||||||
|
1. **HivePress model 與 WC data store 用空 key 讀 meta**(`get_post_meta( $id )`),1.0.x 的 Hook Bus 對空 key 放行、只讀原生。所以 aeav_only 或 cleanup 之後,HP 的 verified / rating、WC 的價格與顧客資料讀成空值(dev20 / dev21 盤點:WC 商品 0/20 可購買)。
|
||||||
|
2. **WC 以直接 SQL 更新 `_stock`、`total_sales`**,扁平表跟不上。
|
||||||
|
3. **shadow_read 會遺失寫入**:WP 的 `update_metadata()` 經 `get_metadata_raw` 讀舊值,攔截器從扁平表回答,新舊相同就不寫原生。
|
||||||
|
4. **Router 在 OR、LIKE、多 post type、跨 group、具名 orderby 上結果錯誤**,HivePress 的 `_sort` 沒被改寫,前台搜尋幾乎沒有變快。
|
||||||
|
5. **陣列值被存成 `"Array"`**。
|
||||||
|
|
||||||
|
帶 key 的單筆讀取本來就沒有速度優勢:`update_meta_cache()` 一次載入全部 key,之後都是記憶體讀取。扁平表真正的價值在**搜尋與篩選**(`BENCHMARK-SCALE.md` 結論 1、2)。
|
||||||
|
|
||||||
|
## 決策(使用者 2026-09-26)
|
||||||
|
|
||||||
|
1. **HP / WC 站採「搜尋索引模式」**:原生 meta 是唯一真實資料來源,扁平表是它的搜尋索引。模式最高到 `shadow_read`;`aeav_only` 與 cleanup 由 Promotion Guard 擋下。
|
||||||
|
2. **移除 WC 扁平化**(Release 2.2:core 的 `post/wc_product` 群組、WC AddOn 的 user `hot` 群組與 hot-zone 欄位)。
|
||||||
|
3. **既有 aeav_only 站(dev20 / 21 / 22)先唯讀盤點**,處置由使用者決定(`notes/ops-aeav-sites-audit-2026-09-26.md`)。
|
||||||
|
4. **分兩版**:2.1 = 止血 + 資料正確性 + Router 與 HP 排序;2.2 = 範圍收斂 + 漂移偵測 + CLI 與診斷。
|
||||||
|
|
||||||
|
## 設計(Release 2.1)
|
||||||
|
|
||||||
|
### 寫入:扁平表跟隨原生
|
||||||
|
|
||||||
|
- **dual_write / shadow_read** 下,pre-filter(`update_/add_/delete_{type}_metadata`)只做路由判斷後回 `null`,讓 WordPress 自己寫原生。
|
||||||
|
- 扁平表在 `added_ / updated_ / deleted_{type}_meta` action(priority 1)裡**從原生重新同步**:直接 SQL 讀原生第一筆 row(`ORDER BY meta_id LIMIT 1`,不經 filter),扁平值 = `to_db( maybe_unserialize( v ) )`,原生沒有 row 時設為 NULL(`TMDO_Hook_Bus::sync_from_native()`)。
|
||||||
|
- `delete_metadata( …, $delete_all )`:`deleted_` action 拿到的 object id 是 0,所以在 `delete_{type}_meta` 先記下 object ids。
|
||||||
|
- `update_metadata_by_mid()` 改 key:新舊兩個 key 都重新同步。
|
||||||
|
- 原生沒有值時只把既有列的欄位設為 NULL,**不新增列**:刪除實體時 adapter 先刪扁平列,WordPress 才逐筆刪 meta。
|
||||||
|
- **aeav_only** 保留原本的 pre-filter short-circuit(非 HP / WC 站仍可使用)。
|
||||||
|
|
||||||
|
### 讀取:shadow_read = 讀原生、抽樣比對扁平
|
||||||
|
|
||||||
|
`intercept_get` 在 shadow_read 回 `$check`(讓 WordPress 讀原生),並以 `tmdo_shadow_compare_sample_rate`(預設 0.1)抽樣比對扁平值、記錄差異。查詢加速不受影響,因為 Router 仍由 `reads_from_flat()`(shadow_read 以上)控制。
|
||||||
|
|
||||||
|
### Promotion Guard
|
||||||
|
|
||||||
|
`TMDO_Promotion_Guard`,由 `TMDO_Mode_Manager::set()`、`TMDO_Post_Migration::cleanup()` 與 `TMDO_Phase_Cleanup::execute()` 呼叫:
|
||||||
|
|
||||||
|
- HivePress 啟用時,post / user / term / comment 不得升到 aeav_only 或執行 cleanup;WooCommerce 啟用時,post / user 不得。
|
||||||
|
- 回傳 `WP_Error( 'promotion_guarded' )`;特殊情況用 filter `tmdo_promotion_guard_allow` 放行。
|
||||||
|
- 已經在 aeav_only 的站**讀取時不強制降級**(降級會讀到空的原生),只在後台顯示 notice。處置用 `wp tmdo bridge-restore-native <entity> --demote`。
|
||||||
|
|
||||||
|
### 回寫原生(bridge-restore-native)
|
||||||
|
|
||||||
|
依模式決定策略:aeav_only 預設 `overwrite`(寫入只到扁平表、原生已過期),其他模式預設 `only_missing`(原生可能較新,例如 WC 直接 SQL 寫庫存)。相同的值不重寫;扁平值等於欄位 default 且原生沒有 row 時不寫(不捏造預設值)。
|
||||||
|
|
||||||
|
### 序列化值
|
||||||
|
|
||||||
|
`text` / `textarea` 遇到陣列或物件時 `maybe_serialize()`,讀出時 `TMDO_Safe_Unserialize`;`text`(VARCHAR(255))超長存 NULL 並記 `text_overflow`。既有的 `"Array"` 由 `wp tmdo repair-array-literals` 從原生修復。
|
||||||
|
|
||||||
|
### Router:只在語意與原生相同時路由
|
||||||
|
|
||||||
|
`TMDO_Post_Query_Router`(`pre_get_posts` priority 1000,排在外掛於預設 priority 加的條件之後):
|
||||||
|
|
||||||
|
- **全有或全無**:任何一個已註冊 key 的 clause 不能路由,整個查詢交回 WordPress。不能路由的情況:
|
||||||
|
- top-level OR
|
||||||
|
- 含已註冊 key 的巢狀 group(排序形狀除外)
|
||||||
|
- 多個 post type
|
||||||
|
- 白名單以外的 compare
|
||||||
|
- type 與欄位語意不同:無 type(CHAR)的數值範圍比較、decimal 欄位的 CHAR 比較、text 欄位用 NUMERIC
|
||||||
|
- **LIKE** 包成 `%esc_like%`。
|
||||||
|
- **每個 group 各自 LEFT JOIN**;扁平表一文章一列(UNIQUE `post_id`),不加 GROUP BY。
|
||||||
|
- 狀態 / 日期副本路由遇到 NOT EXISTS 時停用:INNER JOIN 會漏掉沒有扁平列的文章。
|
||||||
|
- **排序**(`wpdo_post_sort`):
|
||||||
|
- **HivePress `_sort` 形狀**:OR 裡剛好是同 key、同 type 的 NOT EXISTS + EXISTS,其中一個具名,並且是唯一的 orderby。這個 group 從 meta_query 移除,改成 LEFT JOIN 扁平表並 `ORDER BY col, ID DESC`;ASC 時 NULL 排最前,與原生相同。
|
||||||
|
- **具名 clause** 作為唯一的 orderby。
|
||||||
|
- **`meta_key` + `meta_value_num`**(字串欄位用 `meta_value`),加上 `col IS NOT NULL`(原生是 INNER JOIN)。
|
||||||
|
- 欄位排序不等於 `CAST( meta_value AS type )` 時退回原生。
|
||||||
|
- **flat-first**:依扁平欄位排序、且 routed 條件已要求扁平列存在(同一 group、非 NOT EXISTS)、沒有 `post__in` / `author` 等高選擇性條件時,改寫成 `SELECT STRAIGHT_JOIN … FROM flat INNER JOIN wp_posts`,ORDER BY 的 `wp_posts.ID` 改讀等值的 `post_id`,排序只在一張表完成。依日期排序的搜尋不套用(由 posts 驅動較快)。filter `tmdo_router_flat_first` 可關閉。
|
||||||
|
- **維持 WordPress 的 split query**:`posts_request` 移除 `SQL_CALC_FOUND_ROWS`(總數改由快取的 COUNT 回答)會讓 WordPress 以為 request 被改過而撈整列。`split_the_query` 對 routed 查詢恢復 ID-first,`posts_request_ids` 再移除一次 `SQL_CALC_FOUND_ROWS`。
|
||||||
|
|
||||||
|
HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `optimize_query` / `optimize_search`。
|
||||||
|
|
||||||
|
## 後果
|
||||||
|
|
||||||
|
**好處**
|
||||||
|
|
||||||
|
- 資料正確性回到 WordPress 原生語意:HP / WC 的空 key 讀取、直接 SQL 寫入都不再造成資料不一致或遺失。
|
||||||
|
- 查詢加速保留,而且擴及 HivePress 前台的篩選與排序(實測見 `BENCHMARK-SCALE.md`「前台與寫入鏈實測」)。
|
||||||
|
- 任何時候都可以降回 disabled,原生資料完整。
|
||||||
|
|
||||||
|
**代價與限制**
|
||||||
|
|
||||||
|
- **寫入開銷**:dual_write / shadow_read 每次 meta 寫入多一次原生讀取與一次扁平寫入。dev36 實測 HP listing 整條寫入鏈 +29%、WC product +127%(後者在 2.2 移除 WC 扁平化後消失)。
|
||||||
|
- **沒有讀取加速**:帶 key 的單筆讀取仍走原生(WordPress meta cache)。
|
||||||
|
- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 的 `drift-check` 與重新啟用時的增量 backfill(E01 / E02)處理;在那之前,大量匯入後要重新 backfill。
|
||||||
|
- **decimal 欄位搭配 NUMERIC / DECIMAL**:原生先 CAST 成整數(`DECIMAL` 預設 (10,0)),扁平表精確比較,因此帶小數的值在區間邊界上可能不同。dev36 資料沒有這種值。
|
||||||
|
- **text 欄位超過 255 字元**存 NULL,這類值的篩選結果會與原生不同(有 `text_overflow` 記錄)。
|
||||||
|
- HivePress 搜尋 p50 為 321–866 ms(原生 600–1,713 ms),尚未達到 R-2.1 的 300 ms 目標(驗收:`notes/tmdo-acceptance-2.1-2026-09-26.md`)。價格篩選頁的 SQL 只剩約 75 ms、其餘為頁面渲染;純排序(如 `rating__desc`)必須 LEFT JOIN 保留沒有值的文章,無法 flat-first。
|
||||||
|
|
||||||
|
## 不在範圍內
|
||||||
|
|
||||||
|
- aeav_only 的空 key 讀取合併(B02):HP / WC 站不再使用 aeav_only,是否實作依其他站需求決定。
|
||||||
|
- Hot zone 的移除(ADR-003 的後續步驟)。
|
||||||
@@ -1396,8 +1396,13 @@ class TMDO_REST_API {
|
|||||||
): WP_REST_Response {
|
): WP_REST_Response {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
|
// Filters and ordering only on public columns: hidden values must not be
|
||||||
|
// inferable by filtering or sorting on them.
|
||||||
$table = TMDO_Zone_Hot::table( $post_type );
|
$table = TMDO_Zone_Hot::table( $post_type );
|
||||||
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
|
$columns = array_intersect_key(
|
||||||
|
TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ),
|
||||||
|
array_flip( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) )
|
||||||
|
);
|
||||||
|
|
||||||
// Build WHERE clauses from numeric filter params.
|
// Build WHERE clauses from numeric filter params.
|
||||||
$where = array();
|
$where = array();
|
||||||
@@ -1443,8 +1448,8 @@ class TMDO_REST_API {
|
|||||||
'post_type' => $post_type,
|
'post_type' => $post_type,
|
||||||
);
|
);
|
||||||
unset( $row['post_id'], $row['updated_at'] );
|
unset( $row['post_id'], $row['updated_at'] );
|
||||||
// Keep only columns with show_in_rest => true (or all if no registry entry).
|
// Keep only public columns (none when nothing is declared public).
|
||||||
$row = $visible_cols ? array_intersect_key( $row, $visible_cols ) : $row;
|
$row = array_intersect_key( $row, $visible_cols );
|
||||||
return array_merge( $item, $row );
|
return array_merge( $item, $row );
|
||||||
},
|
},
|
||||||
$rows
|
$rows
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ class TMDO_Schema_Registry {
|
|||||||
* - ttl (int) TTL in seconds (Zone B only, null = no expiry).
|
* - ttl (int) TTL in seconds (Zone B only, null = no expiry).
|
||||||
* - cache_group (string) Object cache group (Zone C only).
|
* - cache_group (string) Object cache group (Zone C only).
|
||||||
* - cache_ttl (int) Cache TTL in seconds (Zone C only).
|
* - cache_ttl (int) Cache TTL in seconds (Zone C only).
|
||||||
* - show_in_rest (bool) Whether to expose via GET /wpdo/v1/listings. Default true.
|
* - show_in_rest (bool) Whether to expose via the public GET /wpdo/v1/listings
|
||||||
|
* endpoints. Default false; protected (underscore) keys and
|
||||||
|
* credential-like keys are never exposed (see rest_exposable()).
|
||||||
*/
|
*/
|
||||||
public function register( string $provider, array $config ): void {
|
public function register( string $provider, array $config ): void {
|
||||||
$config = wp_parse_args(
|
$config = wp_parse_args(
|
||||||
@@ -120,7 +122,7 @@ class TMDO_Schema_Registry {
|
|||||||
'cache_group' => '',
|
'cache_group' => '',
|
||||||
'cache_ttl' => HOUR_IN_SECONDS,
|
'cache_ttl' => HOUR_IN_SECONDS,
|
||||||
'provider' => $provider,
|
'provider' => $provider,
|
||||||
'show_in_rest' => true,
|
'show_in_rest' => false,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -276,6 +278,22 @@ class TMDO_Schema_Registry {
|
|||||||
return array_unique( $this->cold_fields[ $post_type ] ?? array() );
|
return array_unique( $this->cold_fields[ $post_type ] ?? array() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a field may appear in the public listings endpoints: declared
|
||||||
|
* show_in_rest, not a protected meta key (leading underscore — WordPress
|
||||||
|
* hides those from public output, e.g. _purchase_note), and not named like a
|
||||||
|
* credential (tokens, secrets, passwords, API keys), whatever it declares.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $field Registered field.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function rest_exposable( array $field ): bool {
|
||||||
|
$key = (string) ( $field['meta_key'] ?? '' );
|
||||||
|
return ! empty( $field['show_in_rest'] )
|
||||||
|
&& 0 !== strpos( $key, '_' )
|
||||||
|
&& 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns hot column names for a post type where show_in_rest is true.
|
* Returns hot column names for a post type where show_in_rest is true.
|
||||||
* Used by REST endpoints to restrict publicly exposed fields.
|
* Used by REST endpoints to restrict publicly exposed fields.
|
||||||
@@ -286,7 +304,7 @@ class TMDO_Schema_Registry {
|
|||||||
public function get_rest_visible_hot_columns( string $post_type ): array {
|
public function get_rest_visible_hot_columns( string $post_type ): array {
|
||||||
$visible = array();
|
$visible = array();
|
||||||
foreach ( $this->fields as $field ) {
|
foreach ( $this->fields as $field ) {
|
||||||
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
|
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
|
||||||
$visible[] = $field['column'];
|
$visible[] = $field['column'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,7 +321,7 @@ class TMDO_Schema_Registry {
|
|||||||
public function get_rest_visible_cold_keys( string $post_type ): array {
|
public function get_rest_visible_cold_keys( string $post_type ): array {
|
||||||
$visible = array();
|
$visible = array();
|
||||||
foreach ( $this->fields as $field ) {
|
foreach ( $this->fields as $field ) {
|
||||||
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
|
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
|
||||||
$visible[] = $field['meta_key'];
|
$visible[] = $field['meta_key'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ final class TMDO_Hook_Bus {
|
|||||||
// ── 刪除攔截 ──────────────────────────────────────────
|
// ── 刪除攔截 ──────────────────────────────────────────
|
||||||
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
|
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
|
||||||
|
|
||||||
|
// ── dual_write / shadow_read:原生寫入完成後,flat 從原生同步 ──
|
||||||
|
// Native is the source of truth in these modes; flat follows what
|
||||||
|
// native actually became (WP may skip or refuse a write).
|
||||||
|
add_action( "added_{$type}_meta", array( self::class, 'on_meta_added' ), 1, 4 );
|
||||||
|
add_action( "updated_{$type}_meta", array( self::class, 'on_meta_updated' ), 1, 4 );
|
||||||
|
add_action( "delete_{$type}_meta", array( self::class, 'before_meta_deleted' ), 1, 4 );
|
||||||
|
add_action( "deleted_{$type}_meta", array( self::class, 'on_meta_deleted' ), 1, 4 );
|
||||||
|
add_filter( "update_{$type}_metadata_by_mid", array( self::class, 'before_update_by_mid' ), 1, 4 );
|
||||||
|
|
||||||
// ── 實體刪除時自動清理 ───────────────────────────────
|
// ── 實體刪除時自動清理 ───────────────────────────────
|
||||||
add_action(
|
add_action(
|
||||||
$adapter->get_delete_hook(),
|
$adapter->get_delete_hook(),
|
||||||
@@ -70,13 +79,13 @@ final class TMDO_Hook_Bus {
|
|||||||
*/
|
*/
|
||||||
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
|
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
|
||||||
|
|
||||||
// 已在短路中 → 避免遞迴
|
$type = self::resolve_type_from_current_filter();
|
||||||
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
|
if ( ! $type ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
$type = self::resolve_type_from_current_filter();
|
// 已在短路中 → 避免遞迴
|
||||||
if ( ! $type ) {
|
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +96,8 @@ final class TMDO_Hook_Bus {
|
|||||||
|
|
||||||
// ── Mode-aware dispatch ──────────────────────────────
|
// ── Mode-aware dispatch ──────────────────────────────
|
||||||
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
|
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
|
||||||
// dual_write: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
|
// dual_write : 放行 WP 寫 EAV;updated_/added_ action 再從原生同步 flat
|
||||||
// shadow_read: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
|
// shadow_read: 同 dual_write
|
||||||
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
|
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
|
||||||
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
|
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
|
||||||
return $check; // disabled
|
return $check; // disabled
|
||||||
@@ -105,6 +114,13 @@ final class TMDO_Hook_Bus {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dual_write / shadow_read: native first. Writing flat here, before WP
|
||||||
|
// decides whether native changes at all (same value, non-matching
|
||||||
|
// prev_value), let flat drift; sync_from_native() runs after the write.
|
||||||
|
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
|
// 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
|
||||||
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。
|
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。
|
||||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' );
|
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' );
|
||||||
@@ -113,23 +129,18 @@ final class TMDO_Hook_Bus {
|
|||||||
|
|
||||||
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
|
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
|
||||||
|
|
||||||
// 若 mode 也要寫 EAV → return null 讓 WP 繼續
|
|
||||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
|
||||||
return null; // dual_write / shadow_read
|
|
||||||
}
|
|
||||||
|
|
||||||
return $flat_result; // aeav_only
|
return $flat_result; // aeav_only
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
|
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
|
||||||
|
|
||||||
// 已在短路中 → 避免遞迴
|
$type = self::resolve_type_from_current_filter();
|
||||||
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
|
if ( ! $type ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
$type = self::resolve_type_from_current_filter();
|
// 已在短路中 → 避免遞迴
|
||||||
if ( ! $type ) {
|
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
|
||||||
return $check;
|
return $check;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +161,12 @@ final class TMDO_Hook_Bus {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dual_write / shadow_read: native first (add($unique) may be refused);
|
||||||
|
// sync_from_native() runs on added_{type}_meta.
|
||||||
|
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
|
// UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
|
||||||
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。
|
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。
|
||||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' );
|
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' );
|
||||||
@@ -158,11 +175,228 @@ final class TMDO_Hook_Bus {
|
|||||||
|
|
||||||
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
|
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
|
||||||
|
|
||||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
return $flat_result;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $flat_result;
|
// ─────────────────────────────────────────────────────────
|
||||||
|
// dual_write / shadow_read:原生寫入後同步 flat
|
||||||
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity IDs whose native rows a pending delete removes, keyed "type:key".
|
||||||
|
*
|
||||||
|
* @var array<string, int[]>
|
||||||
|
*/
|
||||||
|
private static array $pending_deletes = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Old keys of rows renamed through update_metadata_by_mid, keyed by meta ID.
|
||||||
|
*
|
||||||
|
* @var array<string, array{type:string, id:int, key:string}>
|
||||||
|
*/
|
||||||
|
private static array $pending_renames = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the entity's current native value for a key into its flat column
|
||||||
|
* (NULL when native has no row). Runs in dual_write / shadow_read only,
|
||||||
|
* after WP wrote native, so flat always ends up equal to native — also
|
||||||
|
* when WP skipped or refused the write, and when an earlier direct SQL
|
||||||
|
* write had left flat stale. A NULL never creates a row: deleting an
|
||||||
|
* entity drops its flat row before WP deletes the meta rows one by one.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param int $object_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param mixed $meta_value Value the caller wrote (for listeners).
|
||||||
|
* @param string $op 'add' | 'update' | 'delete'.
|
||||||
|
* @param bool $delete_all Whether the delete was a delete_all.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function sync_from_native( string $type, int $object_id, string $meta_key, $meta_value, string $op, bool $delete_all = false ): void {
|
||||||
|
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$field_def = self::field_for( $type, $object_id, $meta_key );
|
||||||
|
if ( ! $field_def || 'flat' !== self::decide_route( $op, $type, $object_id, $meta_key, $meta_value ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$before = self::maybe_read_before_value( $type, $object_id, $meta_key, $field_def, $op );
|
||||||
|
$native = self::native_first_value( $type, $object_id, $meta_key );
|
||||||
|
$result = null === $native
|
||||||
|
? self::clear_column( $type, $object_id, $meta_key, $field_def )
|
||||||
|
: self::perform_upsert( $type, $object_id, $meta_key, maybe_unserialize( $native ), $field_def );
|
||||||
|
|
||||||
|
if ( 'delete' === $op ) {
|
||||||
|
do_action( 'wpdo_after_delete', $type, $object_id, $meta_key, $meta_value, $result, $delete_all, $before );
|
||||||
|
} else {
|
||||||
|
do_action( 'wpdo_after_write', $type, $object_id, $meta_key, $meta_value, $result, $op, $before );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `added_{type}_meta` action.
|
||||||
|
*
|
||||||
|
* @param int $meta_id Meta ID.
|
||||||
|
* @param int $object_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param mixed $meta_value Meta value.
|
||||||
|
*/
|
||||||
|
public static function on_meta_added( $meta_id, $object_id, $meta_key, $meta_value ): void {
|
||||||
|
$type = self::resolve_type_from_meta_hook();
|
||||||
|
if ( $type ) {
|
||||||
|
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'add' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `updated_{type}_meta` action; also re-syncs the old key of a row renamed
|
||||||
|
* through update_metadata_by_mid.
|
||||||
|
*
|
||||||
|
* @param int $meta_id Meta ID.
|
||||||
|
* @param int $object_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param mixed $meta_value Meta value.
|
||||||
|
*/
|
||||||
|
public static function on_meta_updated( $meta_id, $object_id, $meta_key, $meta_value ): void {
|
||||||
|
$type = self::resolve_type_from_meta_hook();
|
||||||
|
if ( ! $type ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'update' );
|
||||||
|
|
||||||
|
$rename_key = $type . ':' . (int) $meta_id;
|
||||||
|
if ( isset( self::$pending_renames[ $rename_key ] ) ) {
|
||||||
|
$old = self::$pending_renames[ $rename_key ];
|
||||||
|
unset( self::$pending_renames[ $rename_key ] );
|
||||||
|
self::sync_from_native( $type, $old['id'], $old['key'], null, 'delete' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `update_{type}_metadata_by_mid` filter: remember the old key when a row
|
||||||
|
* is renamed, so its flat column can follow. Never changes $check.
|
||||||
|
*
|
||||||
|
* @param mixed $check Short-circuit value.
|
||||||
|
* @param int $meta_id Meta ID.
|
||||||
|
* @param mixed $meta_value New value.
|
||||||
|
* @param string|false $meta_key New key, or false to keep it.
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public static function before_update_by_mid( $check, $meta_id, $meta_value, $meta_key ) {
|
||||||
|
$type = self::resolve_type_from_meta_hook();
|
||||||
|
if ( ! $type || false === $meta_key ) {
|
||||||
|
return $check;
|
||||||
|
}
|
||||||
|
$row = self::native_row_by_mid( $type, (int) $meta_id );
|
||||||
|
if ( $row && $row['key'] !== $meta_key && TMDO_Entity_Registry::get_field( $type, $row['key'] ) ) {
|
||||||
|
self::$pending_renames[ $type . ':' . (int) $meta_id ] = array(
|
||||||
|
'type' => $type,
|
||||||
|
'id' => $row['id'],
|
||||||
|
'key' => $row['key'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $check;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `delete_{type}_meta` action (rows still exist): record which entities a
|
||||||
|
* delete touches — a delete_all passes object ID 0.
|
||||||
|
*
|
||||||
|
* @param int[] $meta_ids Meta IDs to delete.
|
||||||
|
* @param int $object_id Entity ID (0 for delete_all).
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param mixed $meta_value Meta value.
|
||||||
|
*/
|
||||||
|
public static function before_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
|
||||||
|
$type = self::resolve_type_from_meta_hook();
|
||||||
|
if ( ! $type || ! TMDO_Entity_Registry::get_field( $type, (string) $meta_key ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ids = array();
|
||||||
|
foreach ( (array) $meta_ids as $mid ) {
|
||||||
|
$row = self::native_row_by_mid( $type, (int) $mid );
|
||||||
|
if ( $row ) {
|
||||||
|
$ids[ $row['id'] ] = $row['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::$pending_deletes[ $type . ':' . $meta_key ] = array_values( $ids );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `deleted_{type}_meta` action: re-sync every entity the delete touched.
|
||||||
|
*
|
||||||
|
* @param int[] $meta_ids Deleted meta IDs.
|
||||||
|
* @param int $object_id Entity ID (0 for delete_all).
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param mixed $meta_value Meta value.
|
||||||
|
*/
|
||||||
|
public static function on_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
|
||||||
|
$type = self::resolve_type_from_meta_hook();
|
||||||
|
if ( ! $type ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pending_key = $type . ':' . $meta_key;
|
||||||
|
$ids = self::$pending_deletes[ $pending_key ] ?? array( (int) $object_id );
|
||||||
|
unset( self::$pending_deletes[ $pending_key ] );
|
||||||
|
|
||||||
|
foreach ( $ids as $id ) {
|
||||||
|
if ( $id > 0 ) {
|
||||||
|
self::sync_from_native( $type, (int) $id, (string) $meta_key, $meta_value, 'delete', 0 === (int) $object_id );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First native value of a key (the one get_*_meta( $id, $key, true ) returns),
|
||||||
|
* read with SQL so no meta filter runs. Null when the entity has no row.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param int $object_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
private static function native_first_value( string $type, int $object_id, string $meta_key ): ?string {
|
||||||
|
global $wpdb;
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||||
|
if ( ! $adapter ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$table = $adapter->get_native_meta_table();
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||||
|
// get_results(): get_var() turns an empty string into null.
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$pk}` LIMIT 1", $object_id, $meta_key ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
return $rows ? (string) $rows[0]['meta_value'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity ID and key of a native meta row.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param int $meta_id Meta ID.
|
||||||
|
* @return array{id:int, key:string}|null
|
||||||
|
*/
|
||||||
|
private static function native_row_by_mid( string $type, int $meta_id ): ?array {
|
||||||
|
global $wpdb;
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||||
|
if ( ! $adapter ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$table = $adapter->get_native_meta_table();
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||||
|
$row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key AS mkey FROM `{$table}` WHERE `{$pk}` = %d", $meta_id ), ARRAY_A );
|
||||||
|
if ( ! $row ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'key' => (string) $row['mkey'],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -291,6 +525,34 @@ final class TMDO_Hook_Bus {
|
|||||||
return 'flat';
|
return 'flat';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a flat column to NULL on the entity's existing row; never insert one.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param int $entity_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param array<string, mixed> $field_def Registered field.
|
||||||
|
* @return bool|null Null when there is no adapter or table.
|
||||||
|
*/
|
||||||
|
private static function clear_column( string $type, int $entity_id, string $meta_key, array $field_def ): ?bool {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $type );
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( $type, $field_def['group'] );
|
||||||
|
if ( ! $adapter || ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
|
$result = $wpdb->update( $table, array( $col => null ), array( $adapter->get_entity_id_column() => $entity_id ) );
|
||||||
|
|
||||||
|
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $field_def['group'] );
|
||||||
|
self::touch_posts_last_changed( $type );
|
||||||
|
|
||||||
|
return false !== $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 執行 Upsert 操作
|
* 執行 Upsert 操作
|
||||||
*/
|
*/
|
||||||
@@ -322,8 +584,8 @@ final class TMDO_Hook_Bus {
|
|||||||
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
|
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
|
||||||
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
|
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
|
||||||
|
|
||||||
// 鎖防遞迴
|
// 鎖防遞迴(含 entity type:user 5 與 post 5 不可互鎖)
|
||||||
$lock_key = $entity_id . ':' . $meta_key;
|
$lock_key = $type . ':' . $entity_id . ':' . $meta_key;
|
||||||
self::$internal_ops[ $lock_key ] = true;
|
self::$internal_ops[ $lock_key ] = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -517,6 +779,12 @@ final class TMDO_Hook_Bus {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dual_write / shadow_read: WP deletes native (also delete_all);
|
||||||
|
// deleted_{type}_meta re-syncs every touched entity from native.
|
||||||
|
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 截取 before value(v1.3.1)
|
// 截取 before value(v1.3.1)
|
||||||
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' );
|
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' );
|
||||||
|
|
||||||
@@ -601,12 +869,7 @@ final class TMDO_Hook_Bus {
|
|||||||
|
|
||||||
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
|
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
|
||||||
|
|
||||||
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
|
return $result !== false; // aeav_only
|
||||||
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result !== false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
@@ -666,6 +929,17 @@ final class TMDO_Hook_Bus {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity type from the meta action / by-mid filter being run
|
||||||
|
* (added_user_meta, deleted_post_meta, update_term_metadata_by_mid, …).
|
||||||
|
*/
|
||||||
|
private static function resolve_type_from_meta_hook(): ?string {
|
||||||
|
if ( preg_match( '/^(?:(?:added|updated|delete|deleted)_(post|user|term|comment)_meta|update_(post|user|term|comment)_metadata_by_mid)$/', current_filter(), $matches ) ) {
|
||||||
|
return '' !== $matches[1] ? $matches[1] : $matches[2];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。
|
* 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,6 +14,16 @@
|
|||||||
* the value Type_Caster returns); a NULL value deletes the native row.
|
* the value Type_Caster returns); a NULL value deletes the native row.
|
||||||
* - Entities with no row in the owning group's table are not touched:
|
* - Entities with no row in the owning group's table are not touched:
|
||||||
* leftover native rows for them are outside the flat data.
|
* leftover native rows for them are outside the flat data.
|
||||||
|
* - A flat value equal to its field default is not written for an entity
|
||||||
|
* that has no native row (e.g. activity.session_count = 0): that would
|
||||||
|
* invent native meta the entity never had.
|
||||||
|
*
|
||||||
|
* Policy (which side wins where both hold a value):
|
||||||
|
* - overwrite — flat wins; the default in aeav_only, where writes only
|
||||||
|
* reached the flat tables and native is stale.
|
||||||
|
* - only_missing — native wins, flat only fills gaps; the default in every
|
||||||
|
* other mode, where native is the source of truth (and may
|
||||||
|
* be newer, e.g. WooCommerce stock written with direct SQL).
|
||||||
*
|
*
|
||||||
* Batched by entity id with a cursor option so an interrupted run resumes; a
|
* Batched by entity id with a cursor option so an interrupted run resumes; a
|
||||||
* final pass re-copies rows updated since the first run started. With
|
* final pass re-copies rows updated since the first run started. With
|
||||||
@@ -52,14 +62,19 @@ final class TMDO_Native_Restorer {
|
|||||||
* @param int $batch Rows per batch.
|
* @param int $batch Rows per batch.
|
||||||
* @param bool $demote After a complete run, switch the entity to dual_write.
|
* @param bool $demote After a complete run, switch the entity to dual_write.
|
||||||
* @param string $group Only this group ('' = every group).
|
* @param string $group Only this group ('' = every group).
|
||||||
* @return array{groups: array<string, array{rows:int, written:int}>, demoted: bool}
|
* @param string $policy 'overwrite' | 'only_missing' | '' (by the entity's mode).
|
||||||
|
* @return array{groups: array<string, array{rows:int, written:int, missing:int, differs:int, same:int, default_skipped:int}>, demoted: bool, policy: string}
|
||||||
*/
|
*/
|
||||||
public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '' ): array {
|
public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '', string $policy = '' ): array {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
|
if ( ! in_array( $policy, array( 'overwrite', 'only_missing' ), true ) ) {
|
||||||
|
$policy = TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ? 'overwrite' : 'only_missing';
|
||||||
|
}
|
||||||
$report = array(
|
$report = array(
|
||||||
'groups' => array(),
|
'groups' => array(),
|
||||||
'demoted' => false,
|
'demoted' => false,
|
||||||
|
'policy' => $policy,
|
||||||
);
|
);
|
||||||
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||||
if ( ! $adapter ) {
|
if ( ! $adapter ) {
|
||||||
@@ -85,6 +100,10 @@ final class TMDO_Native_Restorer {
|
|||||||
$entry = array(
|
$entry = array(
|
||||||
'rows' => 0,
|
'rows' => 0,
|
||||||
'written' => 0,
|
'written' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'differs' => 0,
|
||||||
|
'same' => 0,
|
||||||
|
'default_skipped' => 0,
|
||||||
);
|
);
|
||||||
$key = "{$entity_type}/{$group}";
|
$key = "{$entity_type}/{$group}";
|
||||||
$last = $dry_run ? 0 : (int) ( $cursor[ $key ] ?? 0 );
|
$last = $dry_run ? 0 : (int) ( $cursor[ $key ] ?? 0 );
|
||||||
@@ -94,8 +113,10 @@ final class TMDO_Native_Restorer {
|
|||||||
$entry['rows'] += $fetched;
|
$entry['rows'] += $fetched;
|
||||||
if ( $fetched ) {
|
if ( $fetched ) {
|
||||||
$last = (int) end( $rows )[ $id_col ];
|
$last = (int) end( $rows )[ $id_col ];
|
||||||
|
foreach ( self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, ! $dry_run ) as $count_key => $n ) {
|
||||||
|
$entry[ $count_key ] += $n;
|
||||||
|
}
|
||||||
if ( ! $dry_run ) {
|
if ( ! $dry_run ) {
|
||||||
$entry['written'] += self::write_rows( $entity_type, $group, $adapter, $rows );
|
|
||||||
$cursor[ $key ] = $last;
|
$cursor[ $key ] = $last;
|
||||||
update_option( self::CURSOR_OPTION, $cursor, false );
|
update_option( self::CURSOR_OPTION, $cursor, false );
|
||||||
}
|
}
|
||||||
@@ -112,7 +133,7 @@ final class TMDO_Native_Restorer {
|
|||||||
// Catch-up: writes that landed in the flat tables after a batch passed their row.
|
// Catch-up: writes that landed in the flat tables after a batch passed their row.
|
||||||
foreach ( $tables as $group => $table ) {
|
foreach ( $tables as $group => $table ) {
|
||||||
$rows = self::fetch( $entity_type, $table, $id_col, 't.updated_at >= %s', array( $started[ $entity_type ] ) );
|
$rows = self::fetch( $entity_type, $table, $id_col, 't.updated_at >= %s', array( $started[ $entity_type ] ) );
|
||||||
self::write_rows( $entity_type, $group, $adapter, $rows );
|
self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, true );
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ( array_keys( $tables ) as $group ) {
|
foreach ( array_keys( $tables ) as $group ) {
|
||||||
@@ -235,24 +256,87 @@ final class TMDO_Native_Restorer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the native meta of the keys this group owns for each row.
|
* Compare the native meta of the keys this group owns with the flat rows,
|
||||||
|
* and (when $write) bring native in line according to $policy.
|
||||||
*
|
*
|
||||||
* @param string $entity_type Entity type.
|
* @param string $entity_type Entity type.
|
||||||
* @param string $group Group the rows come from.
|
* @param string $group Group the rows come from.
|
||||||
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||||
* @param array<int, array<string, mixed>> $rows Flat rows.
|
* @param array<int, array<string, mixed>> $rows Flat rows.
|
||||||
* @return int Native values written.
|
* @param string $policy 'overwrite' | 'only_missing'.
|
||||||
|
* @param bool $write False = classify only (dry run).
|
||||||
|
* @return array{written:int, missing:int, differs:int, same:int, default_skipped:int}
|
||||||
*/
|
*/
|
||||||
private static function write_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): int {
|
private static function apply_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows, string $policy, bool $write ): array {
|
||||||
global $wpdb;
|
$counts = array(
|
||||||
if ( empty( $rows ) ) {
|
'written' => 0,
|
||||||
return 0;
|
'missing' => 0,
|
||||||
|
'differs' => 0,
|
||||||
|
'same' => 0,
|
||||||
|
'default_skipped' => 0,
|
||||||
|
);
|
||||||
|
list( $wanted, $fields ) = self::flat_values( $entity_type, $group, $adapter, $rows );
|
||||||
|
if ( empty( $wanted ) ) {
|
||||||
|
return $counts;
|
||||||
}
|
}
|
||||||
$meta_table = $adapter->get_native_meta_table();
|
$native = self::native_values( $entity_type, $adapter, array_keys( $wanted ), array_keys( $fields ) );
|
||||||
$id_col = $adapter->get_entity_id_column();
|
|
||||||
|
|
||||||
$deletes = array();
|
$deletes = array();
|
||||||
$inserts = array();
|
$inserts = array();
|
||||||
|
foreach ( $wanted as $id => $values ) {
|
||||||
|
foreach ( $values as $key => $value ) {
|
||||||
|
$has = isset( $native[ $id ] ) && array_key_exists( $key, $native[ $id ] );
|
||||||
|
if ( null === $value ) {
|
||||||
|
if ( $has ) {
|
||||||
|
++$counts['differs'];
|
||||||
|
if ( 'overwrite' === $policy ) {
|
||||||
|
$deletes[ $key ][] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( ! $has ) {
|
||||||
|
if ( self::is_default( $value, $fields[ $key ] ) ) {
|
||||||
|
++$counts['default_skipped'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++$counts['missing'];
|
||||||
|
$inserts[] = array( $id, $key, $value );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( self::same_value( $native[ $id ][ $key ], $value ) ) {
|
||||||
|
++$counts['same'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++$counts['differs'];
|
||||||
|
if ( 'overwrite' === $policy ) {
|
||||||
|
$deletes[ $key ][] = $id;
|
||||||
|
$inserts[] = array( $id, $key, $value );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $write ) {
|
||||||
|
self::write_native( $entity_type, $adapter, $deletes, $inserts, array_keys( $wanted ) );
|
||||||
|
$counts['written'] = count( $inserts );
|
||||||
|
}
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values this group would write to native, per entity and key (NULL = none),
|
||||||
|
* restricted to the keys the group owns for that entity.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||||
|
* @param array<int, array<string, mixed>> $rows Flat rows.
|
||||||
|
* @return array{0: array<int, array<string, string|null>>, 1: array<string, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
private static function flat_values( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): array {
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
$wanted = array();
|
||||||
|
$fields = array();
|
||||||
foreach ( $rows as $row ) {
|
foreach ( $rows as $row ) {
|
||||||
$id = (int) $row[ $id_col ];
|
$id = (int) $row[ $id_col ];
|
||||||
$post_type = 'post' === $entity_type ? (string) ( $row['__post_type'] ?? '' ) : '';
|
$post_type = 'post' === $entity_type ? (string) ( $row['__post_type'] ?? '' ) : '';
|
||||||
@@ -262,25 +346,93 @@ final class TMDO_Native_Restorer {
|
|||||||
if ( null === $owner || $owner['group'] !== $group || ! array_key_exists( $col, $row ) ) {
|
if ( null === $owner || $owner['group'] !== $group || ! array_key_exists( $col, $row ) ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$deletes[ $field['key'] ][] = $id;
|
$fields[ $field['key'] ] = $field;
|
||||||
if ( null !== $row[ $col ] ) {
|
$wanted[ $id ][ $field['key'] ] = null === $row[ $col ]
|
||||||
$inserts[] = array( $id, $field['key'], (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) ) );
|
? null
|
||||||
|
: (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return array( $wanted, $fields );
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ( $deletes as $meta_key => $ids ) {
|
/**
|
||||||
$in = implode( ',', array_map( 'intval', $ids ) );
|
* First native value per entity and key.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||||
|
* @param int[] $ids Entity IDs.
|
||||||
|
* @param string[] $keys Meta keys.
|
||||||
|
* @return array<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
private static function native_values( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $ids, array $keys ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$meta_table = $adapter->get_native_meta_table();
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
$pk = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
|
||||||
|
$in_ids = implode( ',', array_map( 'intval', $ids ) );
|
||||||
|
$in_keys = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
|
||||||
|
$rows = (array) $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key, meta_value FROM `{$meta_table}` WHERE `{$id_col}` IN ({$in_ids}) AND meta_key IN ({$in_keys}) ORDER BY `{$pk}`", ...$keys ),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
$native = array();
|
||||||
|
foreach ( $rows as $r ) {
|
||||||
|
if ( ! isset( $native[ (int) $r['id'] ][ $r['meta_key'] ] ) ) {
|
||||||
|
$native[ (int) $r['id'] ][ $r['meta_key'] ] = (string) $r['meta_value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $native;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete / insert native rows and clear the entities' meta caches.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||||
|
* @param array<string, int[]> $deletes Meta key → entity IDs to clear first.
|
||||||
|
* @param array<int, array{0:int, 1:string, 2:string}> $inserts Rows to insert.
|
||||||
|
* @param int[] $ids Entities touched.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function write_native( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $deletes, array $inserts, array $ids ): void {
|
||||||
|
global $wpdb;
|
||||||
|
$meta_table = $adapter->get_native_meta_table();
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
|
||||||
|
foreach ( $deletes as $meta_key => $del_ids ) {
|
||||||
|
$in = implode( ',', array_map( 'intval', $del_ids ) );
|
||||||
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$meta_table}` WHERE meta_key = %s AND `{$id_col}` IN ({$in})", $meta_key ) );
|
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$meta_table}` WHERE meta_key = %s AND `{$id_col}` IN ({$in})", $meta_key ) );
|
||||||
}
|
}
|
||||||
if ( $inserts ) {
|
if ( $inserts ) {
|
||||||
$values = implode( ', ', array_fill( 0, count( $inserts ), '(%d, %s, %s)' ) );
|
$values = implode( ', ', array_fill( 0, count( $inserts ), '(%d, %s, %s)' ) );
|
||||||
$wpdb->query( $wpdb->prepare( "INSERT INTO `{$meta_table}` (`{$id_col}`, meta_key, meta_value) VALUES {$values}", ...array_merge( ...$inserts ) ) );
|
$wpdb->query( $wpdb->prepare( "INSERT INTO `{$meta_table}` (`{$id_col}`, meta_key, meta_value) VALUES {$values}", ...array_merge( ...$inserts ) ) );
|
||||||
}
|
}
|
||||||
|
foreach ( $ids as $id ) {
|
||||||
foreach ( array_unique( array_map( static fn( $row ) => (int) $row[ $id_col ], $rows ) ) as $id ) {
|
|
||||||
wp_cache_delete( $id, "{$entity_type}_meta" );
|
wp_cache_delete( $id, "{$entity_type}_meta" );
|
||||||
}
|
}
|
||||||
return count( $inserts );
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a stored value (as WP would store it) equals the field default.
|
||||||
|
*
|
||||||
|
* @param string $value Value as it would be stored.
|
||||||
|
* @param array<string, mixed> $field Field definition.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function is_default( string $value, array $field ): bool {
|
||||||
|
return isset( $field['default'] ) && (string) maybe_serialize( $field['default'] ) === $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native and candidate values are equal (numeric-aware: '3' equals '3.000000').
|
||||||
|
*
|
||||||
|
* @param string $native Native meta value.
|
||||||
|
* @param string $candidate Value from the flat table.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function same_value( string $native, string $candidate ): bool {
|
||||||
|
return $native === $candidate
|
||||||
|
|| ( is_numeric( $native ) && is_numeric( $candidate ) && (float) $native === (float) $candidate );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,18 @@
|
|||||||
* Hook order:
|
* Hook order:
|
||||||
* 1. pre_get_posts — strip registered keys from meta_query, store in
|
* 1. pre_get_posts — strip registered keys from meta_query, store in
|
||||||
* $query->set( 'wpdo_post_clauses', $routed )
|
* $query->set( 'wpdo_post_clauses', $routed )
|
||||||
* 2. posts_join — LEFT JOIN wp_wpdo_post_<group> per routed post_type
|
* 2. posts_join — LEFT JOIN wp_wpdo_post_<group> per routed group
|
||||||
* 3. posts_where — append WHERE conditions targeting flat columns
|
* 3. posts_where — append WHERE conditions targeting flat columns
|
||||||
* 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication
|
*
|
||||||
|
* Flat tables hold one row per post (UNIQUE post_id), so the joins never
|
||||||
|
* multiply rows and no GROUP BY is added.
|
||||||
*
|
*
|
||||||
* Status / date on the flat table (O-1): when the group's post_status /
|
* Status / date on the flat table (O-1): when the group's post_status /
|
||||||
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
|
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
|
||||||
* a single-post-type search with explicit statuses and date ordering
|
* a single-post-type search with explicit statuses and date ordering
|
||||||
* INNER JOINs the table, filters the copies, orders by the copied
|
* INNER JOINs the table, filters the copies and orders by the copied
|
||||||
* post_date and skips GROUP BY (one row per post), so MySQL can walk
|
* post_date, so MySQL can walk `idx_post_status_date` and stop at the
|
||||||
* `idx_post_status_date` and stop at the LIMIT.
|
* 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
|
* 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
|
* match inside the page query); found_posts_query answers from a COUNT of the
|
||||||
@@ -58,12 +60,18 @@ class TMDO_Post_Query_Router {
|
|||||||
/** Query var holding the status / date copy route, when one applies. */
|
/** Query var holding the status / date copy route, when one applies. */
|
||||||
private const COPY_VAR = 'wpdo_post_copy_route';
|
private const COPY_VAR = 'wpdo_post_copy_route';
|
||||||
|
|
||||||
|
/** Query var holding the flat column a routed meta orderby sorts on. */
|
||||||
|
private const SORT_VAR = 'wpdo_post_sort';
|
||||||
|
|
||||||
/** Query var holding the COUNT for found_posts_query. */
|
/** Query var holding the COUNT for found_posts_query. */
|
||||||
private const COUNT_VAR = 'wpdo_post_count_sql';
|
private const COUNT_VAR = 'wpdo_post_count_sql';
|
||||||
|
|
||||||
/** Object cache group for found-rows counts. */
|
/** Object cache group for found-rows counts. */
|
||||||
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
|
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
|
||||||
|
|
||||||
|
/** 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' );
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-request answer to "are this group's copies usable?".
|
* Per-request answer to "are this group's copies usable?".
|
||||||
*
|
*
|
||||||
@@ -77,12 +85,15 @@ class TMDO_Post_Query_Router {
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function register_hooks(): void {
|
public function register_hooks(): void {
|
||||||
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 );
|
// Late, so meta clauses other plugins add at the default priority
|
||||||
|
// (e.g. HivePress search filters and sorting) are already in the query.
|
||||||
|
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 1000, 1 );
|
||||||
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
|
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
|
||||||
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
|
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
|
||||||
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
|
|
||||||
add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
|
add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
|
||||||
add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 );
|
add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 );
|
||||||
|
add_filter( 'split_the_query', array( $this, 'split_the_query' ), 10, 4 );
|
||||||
|
add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 );
|
||||||
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
|
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,60 +114,104 @@ class TMDO_Post_Query_Router {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One post type only: with several, a clause may belong to one type's
|
||||||
|
// group and the others must still match through wp_postmeta.
|
||||||
$post_types = (array) $query->get( 'post_type' );
|
$post_types = (array) $query->get( 'post_type' );
|
||||||
if ( empty( $post_types ) ) {
|
if ( 1 !== count( $post_types ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$pt = (string) reset( $post_types );
|
||||||
|
|
||||||
$raw_meta_query = (array) $query->get( 'meta_query' );
|
$raw_meta_query = (array) $query->get( 'meta_query' );
|
||||||
if ( empty( $raw_meta_query ) ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// All or nothing: routed clauses are ANDed into the WHERE, so the
|
||||||
|
// query is routed only when every registered clause can be, and
|
||||||
|
// the remaining (unregistered) clauses are ANDed too.
|
||||||
|
$relation = strtoupper( (string) ( $raw_meta_query['relation'] ?? 'AND' ) );
|
||||||
|
$orderby = $query->get( 'orderby' );
|
||||||
|
$named = is_array( $orderby ) ? array_keys( $orderby ) : preg_split( '/[\s,]+/', trim( (string) $orderby ) );
|
||||||
|
$sort_name = 1 === count( $named ) ? (string) reset( $named ) : '';
|
||||||
|
$direction = 'ASC' === strtoupper( (string) ( is_array( $orderby ) ? reset( $orderby ) : $query->get( 'order' ) ) ) ? 'ASC' : 'DESC';
|
||||||
$routed = array(); // Keyed by post_type.
|
$routed = array(); // Keyed by post_type.
|
||||||
$remaining = array();
|
$remaining = array();
|
||||||
|
$sort = array();
|
||||||
|
|
||||||
|
// meta_key + orderby meta_value(_num): native INNER JOINs the key
|
||||||
|
// (EXISTS) and orders by meta_value+0 (NUMERIC) or meta_value.
|
||||||
|
$meta_key = $query->get( 'meta_key' );
|
||||||
|
$take_key = is_string( $meta_key ) && in_array( $sort_name, array( 'meta_value', 'meta_value_num' ), true )
|
||||||
|
&& null !== self::field_for( array( 'key' => $meta_key ), $pt );
|
||||||
|
if ( $take_key ) {
|
||||||
|
$field = self::field_for( array( 'key' => $meta_key ), $pt );
|
||||||
|
$type = 'meta_value_num' === $sort_name ? 'NUMERIC' : (string) $query->get( 'meta_type' );
|
||||||
|
$route = self::route_clause(
|
||||||
|
array(
|
||||||
|
'key' => $meta_key,
|
||||||
|
'compare' => 'EXISTS',
|
||||||
|
'type' => $type,
|
||||||
|
),
|
||||||
|
$field
|
||||||
|
);
|
||||||
|
if ( ! $route || ! self::sortable( $route, $field ) || '' !== $query->get( 'meta_value' ) || '' !== $query->get( 'meta_compare' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$routed[ $pt ][] = $route;
|
||||||
|
$sort = self::sort_for( $pt, $route, $direction );
|
||||||
|
}
|
||||||
|
|
||||||
foreach ( $raw_meta_query as $k => $clause ) {
|
foreach ( $raw_meta_query as $k => $clause ) {
|
||||||
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
|
if ( 'relation' === $k || ! is_array( $clause ) ) {
|
||||||
$remaining[ $k ] = $clause;
|
$remaining[ $k ] = $clause;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ( ! isset( $clause['key'] ) ) {
|
||||||
$matched = false;
|
// Sort shape: matches every post, only orders by the key.
|
||||||
foreach ( $post_types as $pt ) {
|
$name = 'AND' === $relation ? self::sort_shape( $clause ) : '';
|
||||||
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], (string) $pt );
|
$field = '' !== $name && $name === $sort_name ? self::field_for( $clause[ $name ], $pt ) : null;
|
||||||
if ( ! $field ) {
|
if ( $field ) {
|
||||||
continue;
|
$route = self::route_clause( array( 'compare' => 'EXISTS' ) + $clause[ $name ], $field );
|
||||||
}
|
if ( ! $route || ! self::sortable( $route, $field ) ) {
|
||||||
// Confirm the field's group is appropriate for this post_type:
|
|
||||||
// either cross-cutting wp_core (any) or the post_type-specific group.
|
|
||||||
$group = $field['group'] ?? '';
|
|
||||||
$is_appropriate = ( 'wp_core' === $group ) || in_array( $group, TMDO_Post_Fields::groups_for_post_type( (string) $pt ), true );
|
|
||||||
if ( ! $is_appropriate ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$column = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] );
|
|
||||||
|
|
||||||
$routed[ $pt ][] = array(
|
|
||||||
'group' => $group,
|
|
||||||
'column' => $column,
|
|
||||||
'value' => $clause['value'] ?? '',
|
|
||||||
'compare' => strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) ),
|
|
||||||
'type' => strtoupper( trim( (string) ( $clause['type'] ?? 'CHAR' ) ) ),
|
|
||||||
);
|
|
||||||
$matched = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! $matched ) {
|
|
||||||
$remaining[ $k ] = $clause;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( empty( $routed ) ) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$sort = self::sort_for( $pt, $route, $direction );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( self::has_registered_key( $clause, $pt ) ) {
|
||||||
|
return; // Nested group.
|
||||||
|
}
|
||||||
|
$remaining[ $k ] = $clause;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$field = self::field_for( $clause, $pt );
|
||||||
|
if ( ! $field ) {
|
||||||
|
$remaining[ $k ] = $clause;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$route = self::route_clause( $clause, $field );
|
||||||
|
if ( ! $route || 'AND' !== $relation ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Named by orderby: only as the sole ordering, on a column that
|
||||||
|
// orders like native (otherwise the name would be lost).
|
||||||
|
if ( is_string( $k ) && in_array( $k, $named, true ) ) {
|
||||||
|
if ( $k !== $sort_name || ! self::sortable( $route, $field ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$sort = self::sort_for( $pt, $route, $direction );
|
||||||
|
}
|
||||||
|
$routed[ $pt ][] = $route;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( empty( $routed ) && empty( $sort ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( $take_key ) {
|
||||||
|
$query->set( 'meta_key', '' );
|
||||||
|
}
|
||||||
|
if ( $sort ) {
|
||||||
|
$query->set( self::SORT_VAR, $sort );
|
||||||
|
}
|
||||||
|
|
||||||
// Preserve relation if surviving clauses still have it.
|
// Preserve relation if surviving clauses still have it.
|
||||||
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
|
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
|
||||||
@@ -173,7 +228,7 @@ class TMDO_Post_Query_Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LEFT JOIN flat tables for each routed post_type.
|
* LEFT JOIN the flat table of each routed group.
|
||||||
*
|
*
|
||||||
* @param string|null $join Current JOIN SQL.
|
* @param string|null $join Current JOIN SQL.
|
||||||
* @param \WP_Query $query Current WP_Query.
|
* @param \WP_Query $query Current WP_Query.
|
||||||
@@ -182,19 +237,20 @@ class TMDO_Post_Query_Router {
|
|||||||
public function posts_join( ?string $join, \WP_Query $query ): string {
|
public function posts_join( ?string $join, \WP_Query $query ): string {
|
||||||
$join = (string) ( $join ?? '' );
|
$join = (string) ( $join ?? '' );
|
||||||
$routed = $query->get( self::QUERY_VAR );
|
$routed = $query->get( self::QUERY_VAR );
|
||||||
if ( empty( $routed ) || ! is_array( $routed ) ) {
|
$sort = $query->get( self::SORT_VAR );
|
||||||
return $join;
|
$pairs = array(); // Pairs of post type and group.
|
||||||
|
foreach ( is_array( $routed ) ? $routed : array() as $post_type => $clauses ) {
|
||||||
|
foreach ( array_column( $clauses, 'group' ) as $group ) {
|
||||||
|
$pairs[] = array( $post_type, $group );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! empty( $sort ) ) {
|
||||||
|
$pairs[] = array( $sort['post_type'], $sort['group'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
foreach ( $routed as $post_type => $clauses ) {
|
foreach ( $pairs as list( $post_type, $group ) ) {
|
||||||
// Each routed clause carries its group; use the first clause's group
|
|
||||||
// (all clauses for a given post_type currently target one group).
|
|
||||||
$group = $clauses[0]['group'] ?? '';
|
|
||||||
if ( '' === $group ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
|
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
|
||||||
$alias = self::table_alias( $post_type, $group );
|
$alias = self::table_alias( $post_type, $group );
|
||||||
|
|
||||||
@@ -203,6 +259,7 @@ class TMDO_Post_Query_Router {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The copy route only applies to single-group searches.
|
||||||
$type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT';
|
$type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT';
|
||||||
$join .= " {$type} JOIN `{$table}` AS `{$alias}` "
|
$join .= " {$type} JOIN `{$table}` AS `{$alias}` "
|
||||||
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
||||||
@@ -248,7 +305,8 @@ class TMDO_Post_Query_Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Order by the flat table's post_date copy when the copy route applies.
|
* Order by the routed meta orderby column (ties by ID), or by the flat
|
||||||
|
* table's post_date copy when the copy route applies.
|
||||||
*
|
*
|
||||||
* @param string|null $orderby Current ORDER BY SQL.
|
* @param string|null $orderby Current ORDER BY SQL.
|
||||||
* @param \WP_Query $query Current WP_Query.
|
* @param \WP_Query $query Current WP_Query.
|
||||||
@@ -256,6 +314,12 @@ class TMDO_Post_Query_Router {
|
|||||||
*/
|
*/
|
||||||
public function posts_orderby( ?string $orderby, \WP_Query $query ): string {
|
public function posts_orderby( ?string $orderby, \WP_Query $query ): string {
|
||||||
$orderby = (string) ( $orderby ?? '' );
|
$orderby = (string) ( $orderby ?? '' );
|
||||||
|
$sort = $query->get( self::SORT_VAR );
|
||||||
|
if ( ! empty( $sort ) ) {
|
||||||
|
global $wpdb;
|
||||||
|
return '`' . self::table_alias( $sort['post_type'], $sort['group'] ) . '`.`' . esc_sql( $sort['column'] ) . '` '
|
||||||
|
. $sort['order'] . ", `{$wpdb->posts}`.`ID` DESC";
|
||||||
|
}
|
||||||
$copy = $query->get( self::COPY_VAR );
|
$copy = $query->get( self::COPY_VAR );
|
||||||
if ( empty( $copy ) ) {
|
if ( empty( $copy ) ) {
|
||||||
return $orderby;
|
return $orderby;
|
||||||
@@ -263,28 +327,6 @@ class TMDO_Post_Query_Router {
|
|||||||
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
|
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
|
|
||||||
*
|
|
||||||
* @param string|null $groupby Current GROUP BY SQL.
|
|
||||||
* @param \WP_Query $query Current WP_Query.
|
|
||||||
* @return string Modified GROUP BY SQL.
|
|
||||||
*/
|
|
||||||
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
|
|
||||||
$groupby = (string) ( $groupby ?? '' );
|
|
||||||
$routed = $query->get( self::QUERY_VAR );
|
|
||||||
if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
|
|
||||||
return $groupby;
|
|
||||||
}
|
|
||||||
|
|
||||||
global $wpdb;
|
|
||||||
if ( '' === trim( $groupby ) ) {
|
|
||||||
$groupby = "`{$wpdb->posts}`.`ID`";
|
|
||||||
}
|
|
||||||
|
|
||||||
return $groupby;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop SQL_CALC_FOUND_ROWS from a routed search; keep its COUNT for later.
|
* Drop SQL_CALC_FOUND_ROWS from a routed search; keep its COUNT for later.
|
||||||
*
|
*
|
||||||
@@ -294,15 +336,45 @@ class TMDO_Post_Query_Router {
|
|||||||
*/
|
*/
|
||||||
public function posts_request( ?string $request, \WP_Query $query ): string {
|
public function posts_request( ?string $request, \WP_Query $query ): string {
|
||||||
$request = (string) ( $request ?? '' );
|
$request = (string) ( $request ?? '' );
|
||||||
if ( empty( $query->get( self::QUERY_VAR ) ) || false === strpos( $request, 'SQL_CALC_FOUND_ROWS' ) ) {
|
$routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) );
|
||||||
|
if ( ! $routed ) {
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
$counted = false !== strpos( $request, 'SQL_CALC_FOUND_ROWS' );
|
||||||
|
$request = self::routed_request( $request, $query );
|
||||||
|
if ( ! $counted ) {
|
||||||
return $request;
|
return $request;
|
||||||
}
|
}
|
||||||
$request = preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
|
|
||||||
$matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
|
$matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
|
||||||
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
|
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
|
||||||
return $request;
|
return $request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep WordPress's split query (IDs first, then cached post objects) on a
|
||||||
|
* routed search: posts_request only dropped SQL_CALC_FOUND_ROWS, yet
|
||||||
|
* WordPress takes any change for a filtered request and selects full rows
|
||||||
|
* (a temporary table sorting whole posts). The split ID request passes
|
||||||
|
* through posts_request_ids, which drops SQL_CALC_FOUND_ROWS again.
|
||||||
|
*
|
||||||
|
* @param bool $split Core decision.
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @param string $old_request Request before posts_request.
|
||||||
|
* @param array<string, string> $clauses Query clauses (fields, limits …).
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function split_the_query( $split, \WP_Query $query, $old_request = '', $clauses = array() ): bool {
|
||||||
|
$routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) );
|
||||||
|
if ( $split || ! $routed ) {
|
||||||
|
return (bool) $split;
|
||||||
|
}
|
||||||
|
global $wpdb;
|
||||||
|
// Core's other conditions: full rows, and a LIMIT under 500 or an external object cache.
|
||||||
|
return "{$wpdb->posts}.*" === ( $clauses['fields'] ?? '' )
|
||||||
|
&& self::routed_request( (string) $old_request, $query ) === $query->request
|
||||||
|
&& ( ( '' !== ( $clauses['limits'] ?? '' ) && (int) $query->get( 'posts_per_page' ) < 500 ) || wp_using_ext_object_cache() );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Answer found rows from the cached COUNT of a routed search.
|
* Answer found rows from the cached COUNT of a routed search.
|
||||||
*
|
*
|
||||||
@@ -363,7 +435,8 @@ class TMDO_Post_Query_Router {
|
|||||||
}
|
}
|
||||||
$post_type = (string) array_key_first( $routed );
|
$post_type = (string) array_key_first( $routed );
|
||||||
$groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) );
|
$groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) );
|
||||||
if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true ) ) {
|
if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true )
|
||||||
|
|| in_array( 'NOT EXISTS', array_column( $routed[ $post_type ], 'compare' ), true ) ) {
|
||||||
return array();
|
return array();
|
||||||
}
|
}
|
||||||
$statuses = $query->get( 'post_status' );
|
$statuses = $query->get( 'post_status' );
|
||||||
@@ -411,6 +484,254 @@ class TMDO_Post_Query_Router {
|
|||||||
return self::$copies_usable[ $group ];
|
return self::$copies_usable[ $group ];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registered field a clause targets for this post type, or null.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $clause Meta query clause.
|
||||||
|
* @param string $post_type Post type slug.
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private static function field_for( array $clause, string $post_type ): ?array {
|
||||||
|
if ( ! is_string( $clause['key'] ?? null ) || ! in_array( $clause['compare_key'] ?? '=', array( '=', '' ), true ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], $post_type );
|
||||||
|
if ( ! $field ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Either cross-cutting wp_core or one of the post type's own groups.
|
||||||
|
$group = $field['group'] ?? '';
|
||||||
|
return ( 'wp_core' === $group || in_array( $group, TMDO_Post_Fields::groups_for_post_type( $post_type ), true ) ) ? $field : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a nested meta query group references a registered key.
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $group Nested group.
|
||||||
|
* @param string $post_type Post type slug.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function has_registered_key( array $group, string $post_type ): bool {
|
||||||
|
foreach ( $group as $clause ) {
|
||||||
|
if ( ! is_array( $clause ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( isset( $clause['key'] ) ? null !== self::field_for( $clause, $post_type ) : self::has_registered_key( $clause, $post_type ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The routed form of a clause, or null when the flat column would not
|
||||||
|
* compare the way WordPress compares the meta_value string.
|
||||||
|
*
|
||||||
|
* Native casts meta_value by the clause type (CHAR when absent); flat
|
||||||
|
* columns are typed. Routed: EXISTS / NOT EXISTS; string columns with
|
||||||
|
* CHAR; numeric columns with a numeric type (no LIKE); integer columns
|
||||||
|
* with CHAR =, !=, IN, NOT IN on plain integers ("10" = "10" either way).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $clause Meta query clause.
|
||||||
|
* @param array<string, mixed> $field Registered field.
|
||||||
|
* @return array{group:string, column:string, value:mixed, compare:string, type:string}|null
|
||||||
|
*/
|
||||||
|
private static function route_clause( array $clause, array $field ): ?array {
|
||||||
|
$compare = strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) );
|
||||||
|
$type = strtoupper( trim( (string) ( $clause['type'] ?? '' ) ) );
|
||||||
|
$type = '' === $type ? 'CHAR' : $type;
|
||||||
|
$value = $clause['value'] ?? '';
|
||||||
|
$kind = $field['type'] ?? 'text';
|
||||||
|
$multi = in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true );
|
||||||
|
|
||||||
|
if ( ! in_array( $compare, self::COMPARES, true ) || ( ! $multi && is_array( $value ) ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$numeric_type = self::is_numeric_type( $type );
|
||||||
|
if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
|
||||||
|
$ok = in_array( $kind, array( 'text', 'textarea', 'enum', 'integer', 'decimal', 'boolean' ), true );
|
||||||
|
} elseif ( in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) ) {
|
||||||
|
$ok = 'CHAR' === $type;
|
||||||
|
} elseif ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) && $numeric_type ) {
|
||||||
|
$ok = ! in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true );
|
||||||
|
} else {
|
||||||
|
$ok = in_array( $kind, array( 'integer', 'boolean' ), true ) && 'CHAR' === $type
|
||||||
|
&& in_array( $compare, array( '=', '!=', '<>', 'IN', 'NOT IN' ), true )
|
||||||
|
&& array() === array_filter( (array) $value, static fn( $v ) => ! is_scalar( $v ) || (string) (int) $v !== (string) $v );
|
||||||
|
}
|
||||||
|
if ( ! $ok ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'group' => (string) $field['group'],
|
||||||
|
'column' => TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ),
|
||||||
|
'value' => $value,
|
||||||
|
'compare' => $compare,
|
||||||
|
'type' => $type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What posts_request makes of a routed request.
|
||||||
|
*
|
||||||
|
* @param string $request SELECT statement.
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function routed_request( string $request, \WP_Query $query ): string {
|
||||||
|
return self::flat_first( self::without_found_rows( $request ), $query );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive a filtered search ordered by a flat column from its flat table.
|
||||||
|
*
|
||||||
|
* MariaDB starts from wp_posts (the type / status index), looks every
|
||||||
|
* candidate up in the flat table and sorts in a temporary table. From the
|
||||||
|
* flat table, with the posts.ID tie-break read as the equal flat post_id,
|
||||||
|
* it sorts one table (dev36, 100k listings: meta_value_num 263 → 147 ms,
|
||||||
|
* HivePress sort + filter 300 → 36 ms). Only when the routed conditions
|
||||||
|
* already require a flat row (LEFT JOIN = INNER JOIN), all on the sort
|
||||||
|
* group, without a selective post filter; `tmdo_router_flat_first` can
|
||||||
|
* turn it off.
|
||||||
|
*
|
||||||
|
* @param string $request SELECT statement.
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function flat_first( string $request, \WP_Query $query ): string {
|
||||||
|
$routed = $query->get( self::QUERY_VAR );
|
||||||
|
$sort = $query->get( self::SORT_VAR );
|
||||||
|
if ( empty( $routed ) || ! is_array( $routed ) || ! 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;
|
||||||
|
}
|
||||||
|
foreach ( array( 'p', 'page_id', 'name', 'pagename', 'attachment_id', 'post__in', 'post_parent', 'post_parent__in', 'author', 'author__in', 'author_name' ) as $var ) {
|
||||||
|
if ( ! empty( $query->get( $var ) ) ) {
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! apply_filters( 'tmdo_router_flat_first', true, $query ) ) {
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $groups[0] );
|
||||||
|
$alias = self::table_alias( $post_type, $groups[0] );
|
||||||
|
$on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
||||||
|
$join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}";
|
||||||
|
if ( 1 !== substr_count( $request, $join ) ) {
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
$flat_first = preg_replace(
|
||||||
|
'/\bFROM\s+' . preg_quote( $wpdb->posts, '/' ) . '\b/',
|
||||||
|
"FROM `{$table}` AS `{$alias}` INNER JOIN {$wpdb->posts} ON {$on}",
|
||||||
|
str_replace( $join, '', $request ),
|
||||||
|
1,
|
||||||
|
$replaced
|
||||||
|
);
|
||||||
|
if ( 1 !== $replaced ) {
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id.
|
||||||
|
$flat_first = preg_replace_callback(
|
||||||
|
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
|
||||||
|
static fn( $m ) => preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)/', "`{$alias}`.`post_id`", $m[0] ),
|
||||||
|
(string) $flat_first
|
||||||
|
);
|
||||||
|
return (string) preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', (string) $flat_first, 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A request without its SQL_CALC_FOUND_ROWS.
|
||||||
|
*
|
||||||
|
* @param string $request SELECT statement.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function without_found_rows( string $request ): string {
|
||||||
|
return (string) preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a meta query type casts to a number (NUMERIC, SIGNED, DECIMAL(p,s) …).
|
||||||
|
*
|
||||||
|
* @param string $type Upper-cased clause type.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function is_numeric_type( string $type ): bool {
|
||||||
|
return 1 === preg_match( '/^(NUMERIC|SIGNED|UNSIGNED|DECIMAL)(\(\d+(,\s?\d+)?\))?$/', $type );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether ordering by the flat column matches native ordering by
|
||||||
|
* CAST(meta_value AS type): numbers by a numeric type, strings by CHAR.
|
||||||
|
*
|
||||||
|
* @param array{type:string} $route Routed clause.
|
||||||
|
* @param array<string, mixed> $field Registered field.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function sortable( array $route, array $field ): bool {
|
||||||
|
$kind = $field['type'] ?? 'text';
|
||||||
|
if ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) ) {
|
||||||
|
return self::is_numeric_type( $route['type'] );
|
||||||
|
}
|
||||||
|
return in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) && 'CHAR' === $route['type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ordering a routed meta orderby resolves to.
|
||||||
|
*
|
||||||
|
* @param string $post_type Post type slug.
|
||||||
|
* @param array{group:string, column:string} $route Routed clause.
|
||||||
|
* @param string $direction ASC or DESC.
|
||||||
|
* @return array{post_type:string, group:string, column:string, order:string}
|
||||||
|
*/
|
||||||
|
private static function sort_for( string $post_type, array $route, string $direction ): array {
|
||||||
|
return array(
|
||||||
|
'post_type' => $post_type,
|
||||||
|
'group' => $route['group'],
|
||||||
|
'column' => $route['column'],
|
||||||
|
'order' => $direction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clause name of a "sort by a key, keep posts without it" group —
|
||||||
|
* relation OR of exactly NOT EXISTS and EXISTS on one key and type, one
|
||||||
|
* of them named (HivePress search sorting) — or '' for any other group.
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $group Nested meta query group.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function sort_shape( array $group ): string {
|
||||||
|
if ( 'OR' !== strtoupper( (string) ( $group['relation'] ?? '' ) ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
unset( $group['relation'] );
|
||||||
|
if ( 2 !== count( $group ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$name = '';
|
||||||
|
$compares = array();
|
||||||
|
foreach ( $group as $k => $clause ) {
|
||||||
|
if ( ! is_array( $clause ) || ! is_string( $clause['key'] ?? null ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$compares[] = strtoupper( (string) ( $clause['compare'] ?? '=' ) );
|
||||||
|
$name = is_string( $k ) ? $k : $name;
|
||||||
|
}
|
||||||
|
list( $a, $b ) = array_values( $group );
|
||||||
|
sort( $compares );
|
||||||
|
$same = $a['key'] === $b['key'] && strtoupper( (string) ( $a['type'] ?? '' ) ) === strtoupper( (string) ( $b['type'] ?? '' ) );
|
||||||
|
return array( 'EXISTS', 'NOT EXISTS' ) === $compares && $same ? $name : '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a deterministic JOIN alias for a (post_type, group) pair.
|
* Generate a deterministic JOIN alias for a (post_type, group) pair.
|
||||||
*
|
*
|
||||||
@@ -429,9 +750,8 @@ class TMDO_Post_Query_Router {
|
|||||||
* @return string Sanitized operator (defaults to '=').
|
* @return string Sanitized operator (defaults to '=').
|
||||||
*/
|
*/
|
||||||
private static function sanitize_compare( string $compare ): string {
|
private static function sanitize_compare( string $compare ): string {
|
||||||
$allowed = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' );
|
|
||||||
$compare = strtoupper( trim( $compare ) );
|
$compare = strtoupper( trim( $compare ) );
|
||||||
return in_array( $compare, $allowed, true ) ? $compare : '=';
|
return in_array( $compare, self::COMPARES, true ) ? $compare : '=';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -468,6 +788,10 @@ class TMDO_Post_Query_Router {
|
|||||||
return ' AND ' . $col_sql . ' ' . $op;
|
return ' AND ' . $col_sql . ' ' . $op;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true ) ) {
|
||||||
|
$value = '%' . $wpdb->esc_like( (string) $value ) . '%';
|
||||||
|
}
|
||||||
|
|
||||||
return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value );
|
return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
|
|||||||
Requires at least: 6.0
|
Requires at least: 6.0
|
||||||
Tested up to: 6.9
|
Tested up to: 6.9
|
||||||
Requires PHP: 8.1
|
Requires PHP: 8.1
|
||||||
Stable tag: 1.0.0
|
Stable tag: 1.1.1
|
||||||
License: GPLv2 or later
|
License: GPLv2 or later
|
||||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
|
||||||
@@ -107,6 +107,21 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
|
|||||||
|
|
||||||
== Changelog ==
|
== Changelog ==
|
||||||
|
|
||||||
|
= 1.1.1 =
|
||||||
|
* Security: the public GET /wpdo/v1/listings endpoints returned every registered field (show_in_rest defaulted to true), including private values such as integration tokens and WooCommerce purchase notes. Fields are now private unless registered with show_in_rest => true, and protected (underscore) or credential-like keys are never exposed
|
||||||
|
* Security: the listings endpoint no longer returns whole rows when no field is public, and only filters / sorts on public columns
|
||||||
|
|
||||||
|
= 1.1.0 =
|
||||||
|
* Search-index mode for HivePress / WooCommerce sites (docs/adr-004): native meta stays the source of truth, flat tables are its search index
|
||||||
|
* Promotion Guard: aeav_only and cleanup are refused while HivePress or WooCommerce is active (filter `tmdo_promotion_guard_allow`)
|
||||||
|
* shadow_read reads native meta and samples a flat comparison — fixes lost native writes
|
||||||
|
* dual_write / shadow_read: flat values follow native after every write (added_/updated_/deleted_ meta actions, delete_all, key change by meta_id)
|
||||||
|
* Arrays in text fields are stored serialized, never "Array"; `wp tmdo repair-array-literals`
|
||||||
|
* `wp tmdo bridge-restore-native`: overwrite in aeav_only, only-missing otherwise; no invented default rows
|
||||||
|
* Post query router: routes only when the result equals native (OR, several post types, untyped numeric compares stay native), LIKE wildcards, one join per group, no GROUP BY, HivePress `_sort` / named / meta_value_num ordering, WordPress split query kept, flat-sorted searches driven from the flat table (filter `tmdo_router_flat_first`)
|
||||||
|
* Deleting an entity no longer leaves an all-NULL flat row behind
|
||||||
|
* Zone tables that were never created are never queried
|
||||||
|
|
||||||
= 1.0.0 =
|
= 1.0.0 =
|
||||||
* Supersedes `wp-data-optimizer` v3.4.6 — that plugin is retired; this one is the single unified engine
|
* Supersedes `wp-data-optimizer` v3.4.6 — that plugin is retired; this one is the single unified engine
|
||||||
* Version numbering restarts at 1.0.0; entries below 3.4.6 are the predecessor's history, kept for archaeology
|
* Version numbering restarts at 1.0.0; entries below 3.4.6 are the predecessor's history, kept for archaeology
|
||||||
|
|||||||
@@ -202,14 +202,89 @@ class HookBusIntegrationTest extends TestCase {
|
|||||||
$this->assertTrue( $result, 'aeav_only must return true to short-circuit EAV write' );
|
$this->assertTrue( $result, 'aeav_only must return true to short-circuit EAV write' );
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_writes_flat_and_returns_null_in_dual_write_mode(): void {
|
/**
|
||||||
|
* dual_write: the pre-filter only lets WP write native; flat is synced
|
||||||
|
* from native afterwards (updated_/added_ actions → sync_from_native).
|
||||||
|
*/
|
||||||
|
public function test_update_leaves_flat_to_the_native_sync_in_dual_write_mode(): void {
|
||||||
|
global $wpdb;
|
||||||
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
|
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
|
||||||
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
|
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
|
||||||
|
|
||||||
$result = WPDO_Hook_Bus::intercept_update( null, 3, 'first_name', 'Carol', '' );
|
$result = WPDO_Hook_Bus::intercept_update( null, 3, 'first_name', 'Carol', '' );
|
||||||
|
|
||||||
$this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) );
|
|
||||||
$this->assertNull( $result, 'dual_write must return null so WP continues to write EAV' );
|
$this->assertNull( $result, 'dual_write must return null so WP continues to write EAV' );
|
||||||
|
$this->assertNull( $this->read_flat_first_name( 3 ), 'no flat write before native is written' );
|
||||||
|
|
||||||
|
$this->with_usermeta(
|
||||||
|
function () use ( $wpdb ) {
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 3, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ) );
|
||||||
|
|
||||||
|
WPDO_Hook_Bus::sync_from_native( 'user', 3, 'first_name', 'Carol', 'update' );
|
||||||
|
|
||||||
|
$this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* delete_all passes object ID 0: the delete_ action records the touched
|
||||||
|
* users from the meta IDs, the deleted_ action re-syncs each to NULL.
|
||||||
|
*/
|
||||||
|
public function test_delete_all_resyncs_every_touched_entity_in_dual_write_mode(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
|
||||||
|
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 21, 'first_name' => 'A' ) );
|
||||||
|
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 22, 'first_name' => 'B' ) );
|
||||||
|
|
||||||
|
$this->with_usermeta(
|
||||||
|
function () use ( $wpdb ) {
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 21, 'meta_key' => 'first_name', 'meta_value' => 'A' ) );
|
||||||
|
$mid_a = (int) $wpdb->insert_id;
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 22, 'meta_key' => 'first_name', 'meta_value' => 'B' ) );
|
||||||
|
$mid_b = (int) $wpdb->insert_id;
|
||||||
|
|
||||||
|
$GLOBALS['_wp_current_filter'] = 'delete_user_meta';
|
||||||
|
WPDO_Hook_Bus::before_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' );
|
||||||
|
$wpdb->query( "DELETE FROM `{$wpdb->usermeta}` WHERE meta_key = 'first_name'" );
|
||||||
|
$GLOBALS['_wp_current_filter'] = 'deleted_user_meta';
|
||||||
|
WPDO_Hook_Bus::on_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' );
|
||||||
|
|
||||||
|
$this->assertNull( $this->read_flat_first_name( 21 ) );
|
||||||
|
$this->assertNull( $this->read_flat_first_name( 22 ) );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting an entity drops its flat row first (adapter delete hook), then
|
||||||
|
* WordPress deletes each meta row; syncing those deletions must not bring
|
||||||
|
* the row back as all NULL.
|
||||||
|
*/
|
||||||
|
public function test_sync_of_a_deleted_key_does_not_recreate_a_missing_row(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
|
||||||
|
|
||||||
|
$this->with_usermeta(
|
||||||
|
function () use ( $wpdb ) {
|
||||||
|
WPDO_Hook_Bus::sync_from_native( 'user', 31, 'first_name', 'Gone', 'delete' );
|
||||||
|
|
||||||
|
$this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '` WHERE user_id = 31' ) );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run $fn with a native usermeta table, dropped afterwards.
|
||||||
|
*/
|
||||||
|
private function with_usermeta( callable $fn ): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id) ) ENGINE=InnoDB" );
|
||||||
|
try {
|
||||||
|
$fn();
|
||||||
|
} finally {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_passthrough_for_unregistered_key(): void {
|
public function test_update_passthrough_for_unregistered_key(): void {
|
||||||
|
|||||||
@@ -139,4 +139,77 @@ class NativeRestorerTest extends TestCase {
|
|||||||
|
|
||||||
$this->assertSame( array( '9' ), $this->native( 1, 'level' ) );
|
$this->assertSame( array( '9' ), $this->native( 1, 'level' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function set_user_mode( string $mode ): void {
|
||||||
|
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => $mode );
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native is the source of truth outside aeav_only (e.g. WooCommerce writes
|
||||||
|
* stock with direct SQL): the default policy there only fills gaps.
|
||||||
|
*/
|
||||||
|
public function test_native_authoritative_modes_only_fill_missing_rows(): void {
|
||||||
|
$this->set_user_mode( 'dual_write' );
|
||||||
|
|
||||||
|
$report = TMDO_Native_Restorer::run( 'user', false );
|
||||||
|
|
||||||
|
$this->assertSame( array( '1' ), $this->native( 1, 'level' ), 'differing native value kept' );
|
||||||
|
$this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ), 'missing native row filled' );
|
||||||
|
$this->assertSame( array( '5' ), $this->native( 2, 'level' ) );
|
||||||
|
$this->assertSame( array( 'stale' ), $this->native( 2, 'tags' ), 'NULL flat value does not delete native here' );
|
||||||
|
$this->assertSame( 'only_missing', $report['policy'] );
|
||||||
|
$this->assertSame( 2, $report['groups']['rn_profile']['missing'] );
|
||||||
|
$this->assertSame( 2, $report['groups']['rn_profile']['differs'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_overwrite_policy_replaces_native_in_any_mode(): void {
|
||||||
|
$this->set_user_mode( 'dual_write' );
|
||||||
|
|
||||||
|
TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'overwrite' );
|
||||||
|
|
||||||
|
$this->assertSame( array( '3' ), $this->native( 1, 'level' ) );
|
||||||
|
$this->assertSame( array(), $this->native( 2, 'tags' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_only_missing_policy_keeps_native_in_aeav_only(): void {
|
||||||
|
TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'only_missing' );
|
||||||
|
|
||||||
|
$this->assertSame( array( '1' ), $this->native( 1, 'level' ) );
|
||||||
|
$this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dry_run_classifies_without_writing(): void {
|
||||||
|
$report = TMDO_Native_Restorer::run( 'user', true );
|
||||||
|
|
||||||
|
$this->assertSame( 'overwrite', $report['policy'], 'aeav_only: flat is the source of truth' );
|
||||||
|
$this->assertSame( 2, $report['groups']['rn_profile']['missing'] );
|
||||||
|
$this->assertSame( 2, $report['groups']['rn_profile']['differs'] );
|
||||||
|
$this->assertSame( 0, $report['groups']['rn_profile']['same'] );
|
||||||
|
$this->assertSame( array( '1' ), $this->native( 1, 'level' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flat column holding its field default for an entity that never had
|
||||||
|
* the key (e.g. activity.session_count = 0) is not written back.
|
||||||
|
*/
|
||||||
|
public function test_defaults_are_not_invented_as_native_rows(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$table = 'wp_itest_wpdo_user_rn_counts';
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, visits BIGINT(20) DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( "INSERT INTO `{$table}` (user_id, visits, updated_at) VALUES (1, 0, '2020-01-01'), (2, 7, '2020-01-01')" );
|
||||||
|
WPDO_Entity_Registry::register_group( 'user', 'rn_counts', array( array( 'key' => 'visits', 'type' => 'integer', 'default' => 0 ) ) );
|
||||||
|
WPDO_Schema_Manager::flush_table_exists_cache();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rn_counts' );
|
||||||
|
|
||||||
|
$this->assertSame( array(), $this->native( 1, 'visits' ), 'default 0 not invented' );
|
||||||
|
$this->assertSame( array( '7' ), $this->native( 2, 'visits' ) );
|
||||||
|
$this->assertSame( 1, $report['groups']['rn_counts']['default_skipped'] );
|
||||||
|
} finally {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ class PostQueryRouterCopyTest extends TestCase {
|
|||||||
$mode->setValue( null, null );
|
$mode->setValue( null, null );
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{join:string, where:string, orderby:string, groupby:string} */
|
/** @return array{join:string, where:string, orderby:string} */
|
||||||
private function sql( array $vars ): array {
|
private function sql( array $vars ): array {
|
||||||
$query = new WP_Query();
|
$query = new WP_Query();
|
||||||
foreach ( array_merge(
|
foreach ( array_merge(
|
||||||
array(
|
array(
|
||||||
'post_type' => 'rc_item',
|
'post_type' => 'rc_item',
|
||||||
'post_status' => 'publish',
|
'post_status' => 'publish',
|
||||||
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN' ) ),
|
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
|
||||||
),
|
),
|
||||||
$vars
|
$vars
|
||||||
) as $k => $v ) {
|
) as $k => $v ) {
|
||||||
@@ -77,7 +77,6 @@ class PostQueryRouterCopyTest extends TestCase {
|
|||||||
'join' => $router->posts_join( '', $query ),
|
'join' => $router->posts_join( '', $query ),
|
||||||
'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ),
|
'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ),
|
||||||
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
|
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
|
||||||
'groupby' => $router->posts_groupby( '', $query ),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +84,6 @@ class PostQueryRouterCopyTest extends TestCase {
|
|||||||
$this->assertStringContainsString( 'LEFT JOIN', $sql['join'] );
|
$this->assertStringContainsString( 'LEFT JOIN', $sql['join'] );
|
||||||
$this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] );
|
$this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] );
|
||||||
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||||
$this->assertSame( '`wp_itest_posts`.`ID`', $sql['groupby'] );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void {
|
public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void {
|
||||||
@@ -95,7 +93,6 @@ class PostQueryRouterCopyTest extends TestCase {
|
|||||||
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] );
|
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] );
|
||||||
$this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' );
|
$this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' );
|
||||||
$this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] );
|
$this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] );
|
||||||
$this->assertSame( '', $sql['groupby'], 'one row per post: no GROUP BY, so LIMIT can stop early' );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_several_explicit_statuses_are_kept(): void {
|
public function test_several_explicit_statuses_are_kept(): void {
|
||||||
@@ -123,4 +120,9 @@ class PostQueryRouterCopyTest extends TestCase {
|
|||||||
$this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) );
|
$this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) );
|
||||||
$this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) );
|
$this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_not_exists_keeps_the_legacy_shape(): void {
|
||||||
|
// The INNER JOIN would drop posts without a flat row, which NOT EXISTS must match.
|
||||||
|
$this->assert_legacy_shape( $this->sql( array( 'meta_query' => array( array( 'key' => 'rc_price', 'compare' => 'NOT EXISTS' ) ) ) ) );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,12 +75,12 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A routed query plus the SQL WordPress would build for it (page size 1). */
|
/** A routed query plus the SQL WordPress would build for it (page size 1). */
|
||||||
private function routed(): array {
|
private function routed( array $clause = array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=', 'type' => 'NUMERIC' ) ): array {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$query = new WP_Query();
|
$query = new WP_Query();
|
||||||
$query->set( 'post_type', 'fr_item' );
|
$query->set( 'post_type', 'fr_item' );
|
||||||
$query->set( 'post_status', 'publish' );
|
$query->set( 'post_status', 'publish' );
|
||||||
$query->set( 'meta_query', array( array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=' ) ) );
|
$query->set( 'meta_query', array( $clause ) );
|
||||||
$router = new TMDO_Post_Query_Router();
|
$router = new TMDO_Post_Query_Router();
|
||||||
$router->pre_get_posts( $query );
|
$router->pre_get_posts( $query );
|
||||||
|
|
||||||
@@ -133,4 +133,129 @@ class PostQueryRouterFoundRowsTest extends TestCase {
|
|||||||
$this->assertNotSame( $before, $after_write );
|
$this->assertNotSame( $before, $after_write );
|
||||||
$this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) );
|
$this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── flat-first: a filtered search ordered by a flat column ─────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A routed search ordered by the flat price (named clause), with the
|
||||||
|
* posts.ID tie-break a plugin such as HivePress appends.
|
||||||
|
*
|
||||||
|
* @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string}
|
||||||
|
*/
|
||||||
|
private function sorted( string $compare = '>=' ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$query = new WP_Query();
|
||||||
|
$query->set( 'post_type', 'fr_item' );
|
||||||
|
$query->set( 'post_status', 'publish' );
|
||||||
|
$query->set( 'meta_query', array( 'price' => array( 'key' => 'fr_price', 'value' => 15, 'compare' => $compare, 'type' => 'NUMERIC' ) ) );
|
||||||
|
$query->set( 'orderby', 'price' );
|
||||||
|
$query->set( 'order', 'DESC' );
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
$router->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$request = "SELECT SQL_CALC_FOUND_ROWS {$wpdb->posts}.ID FROM {$wpdb->posts} " . $router->posts_join( '', $query )
|
||||||
|
. " WHERE 1=1 AND {$wpdb->posts}.post_type = 'fr_item' AND (({$wpdb->posts}.post_status = 'publish'))" . $router->posts_where( '', $query )
|
||||||
|
. ' ORDER BY ' . $router->posts_orderby( '', $query ) . ", {$wpdb->posts}.ID DESC LIMIT 0, 10";
|
||||||
|
return array( $router, $query, $request );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sorted_filtered_request_drives_from_the_flat_table_with_the_same_rows(): void {
|
||||||
|
global $wpdb;
|
||||||
|
list( $router, $query, $request ) = $this->sorted();
|
||||||
|
|
||||||
|
$sql = $router->posts_request( $request, $query );
|
||||||
|
|
||||||
|
$this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql );
|
||||||
|
$this->assertStringContainsString( 'FROM `' . self::TABLE . '` AS `wpdoflat_fr_item_fr_core` INNER JOIN ' . $wpdb->posts . ' ON', $sql );
|
||||||
|
$this->assertStringNotContainsString( 'LEFT JOIN `' . self::TABLE . '`', $sql );
|
||||||
|
$this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id' );
|
||||||
|
$this->assertSame( array( '3', '2' ), $wpdb->get_col( $sql ) );
|
||||||
|
$this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $request ) ), $wpdb->get_col( $sql ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_date_ordered_filtered_request_keeps_wordpress_join_order(): void {
|
||||||
|
list( $router, $query, $request ) = $this->routed();
|
||||||
|
|
||||||
|
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_flat_first_needs_a_condition_that_rejects_posts_without_a_flat_row(): void {
|
||||||
|
list( $router, $query, $request ) = $this->sorted( 'NOT EXISTS' );
|
||||||
|
|
||||||
|
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_flat_first_is_skipped_for_selective_post_filters(): void {
|
||||||
|
list( $router, $query, $request ) = $this->sorted();
|
||||||
|
$query->set( 'post__in', array( 2 ) );
|
||||||
|
|
||||||
|
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_flat_first_can_be_turned_off(): void {
|
||||||
|
list( $router, $query, $request ) = $this->sorted();
|
||||||
|
$off = static fn() => false;
|
||||||
|
add_filter( 'tmdo_router_flat_first', $off );
|
||||||
|
try {
|
||||||
|
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
|
||||||
|
} finally {
|
||||||
|
remove_filter( 'tmdo_router_flat_first', $off );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_flat_first_keeps_the_split_query(): void {
|
||||||
|
global $wpdb;
|
||||||
|
list( $router, $query, $request ) = $this->sorted();
|
||||||
|
$old_request = str_replace( "{$wpdb->posts}.ID FROM", "{$wpdb->posts}.* FROM", $request );
|
||||||
|
$query->request = $router->posts_request( $old_request, $query );
|
||||||
|
$query->set( 'posts_per_page', 10 );
|
||||||
|
|
||||||
|
$this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $query->request );
|
||||||
|
$this->assertTrue( $router->split_the_query( false, $query, $old_request, array( 'fields' => "{$wpdb->posts}.*", 'limits' => 'LIMIT 0, 10' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── split query: IDs first, then cached post objects ──────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPress's full-row request for a routed query, before and after posts_request.
|
||||||
|
*
|
||||||
|
* @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string, 3:array<string, string>}
|
||||||
|
*/
|
||||||
|
private function full_rows(): array {
|
||||||
|
global $wpdb;
|
||||||
|
list( $router, $query, $request ) = $this->routed();
|
||||||
|
$old_request = str_replace( "{$wpdb->posts}.ID FROM", "{$wpdb->posts}.* FROM", $request );
|
||||||
|
$query->request = $router->posts_request( $old_request, $query );
|
||||||
|
$query->set( 'posts_per_page', 1 );
|
||||||
|
return array( $router, $query, $old_request, array( 'fields' => "{$wpdb->posts}.*", 'limits' => 'LIMIT 0, 1' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_routed_full_row_request_is_still_split(): void {
|
||||||
|
list( $router, $query, $old_request, $clauses ) = $this->full_rows();
|
||||||
|
|
||||||
|
$this->assertTrue( $router->split_the_query( false, $query, $old_request, $clauses ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_request_changed_by_others_is_not_split(): void {
|
||||||
|
list( $router, $query, $old_request, $clauses ) = $this->full_rows();
|
||||||
|
$query->request .= ' /* another plugin */';
|
||||||
|
|
||||||
|
$this->assertFalse( $router->split_the_query( false, $query, $old_request, $clauses ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unrouted_query_keeps_the_core_decision(): void {
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
|
||||||
|
$this->assertFalse( $router->split_the_query( false, new WP_Query(), 'SELECT 1', array( 'fields' => 'wp_itest_posts.*', 'limits' => 'LIMIT 0, 1' ) ) );
|
||||||
|
$this->assertTrue( $router->split_the_query( true, new WP_Query(), 'SELECT 1', array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_split_id_request_goes_through_posts_request_too(): void {
|
||||||
|
unset( $GLOBALS['_wp_filter_callbacks']['posts_request_ids'], $GLOBALS['_wp_filter_callbacks']['split_the_query'] );
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
$router->register_hooks();
|
||||||
|
|
||||||
|
$this->assertContains( array( $router, 'posts_request' ), $GLOBALS['_wp_filter_callbacks']['posts_request_ids'] ?? array() );
|
||||||
|
$this->assertContains( array( $router, 'split_the_query' ), $GLOBALS['_wp_filter_callbacks']['split_the_query'] ?? array() );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meta orderby on the flat table: the "sort by an attribute, keep posts
|
||||||
|
* without it" shape (relation OR of NOT EXISTS + EXISTS on one key, named
|
||||||
|
* clause in orderby — HivePress search sorting), orderby on a named routed
|
||||||
|
* clause, and meta_key + meta_value(_num).
|
||||||
|
*
|
||||||
|
* Only when the flat column orders the way native CASTs meta_value;
|
||||||
|
* otherwise the whole query stays native.
|
||||||
|
*/
|
||||||
|
class PostQueryRouterOrderTest extends TestCase {
|
||||||
|
|
||||||
|
private const ALIAS = '`wpdoflat_product_wc_product`';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Post_Fields::register_entity_fields();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
$cache = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$cache->setAccessible( true );
|
||||||
|
$cache->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tearDownAfterClass(): void {
|
||||||
|
$cache = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$cache->setAccessible( true );
|
||||||
|
$cache->setValue( null, null );
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $vars Query vars (post_type defaults to product).
|
||||||
|
* @return array{query:WP_Query, join:string, where:string, orderby:string}
|
||||||
|
*/
|
||||||
|
private function route( array $vars ): array {
|
||||||
|
$query = new WP_Query();
|
||||||
|
foreach ( $vars + array( 'post_type' => 'product' ) as $k => $v ) {
|
||||||
|
$query->set( $k, $v );
|
||||||
|
}
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
$router->pre_get_posts( $query );
|
||||||
|
return array(
|
||||||
|
'query' => $query,
|
||||||
|
'join' => $router->posts_join( '', $query ),
|
||||||
|
'where' => $router->posts_where( '', $query ),
|
||||||
|
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $type Clause type.
|
||||||
|
* @return array<int|string, mixed>
|
||||||
|
*/
|
||||||
|
private function sort_shape( string $type ): array {
|
||||||
|
return array(
|
||||||
|
array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
'price__order' => array( 'key' => '_price', 'type' => $type, 'compare' => 'NOT EXISTS' ),
|
||||||
|
array( 'key' => '_price', 'type' => $type, 'compare' => 'EXISTS' ),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sort_shape_left_joins_and_orders_by_the_flat_column_without_filtering(): void {
|
||||||
|
$sql = $this->route( array( 'meta_query' => $this->sort_shape( 'DECIMAL' ), 'orderby' => 'price__order', 'order' => 'ASC' ) );
|
||||||
|
|
||||||
|
$this->assertSame( array(), $sql['query']->get( 'meta_query' ), 'sort group removed from the native meta_query' );
|
||||||
|
$this->assertStringContainsString( 'LEFT JOIN `wp_itest_wpdo_post_wc_product` AS ' . self::ALIAS, $sql['join'] );
|
||||||
|
$this->assertStringNotContainsString( '_price', $sql['where'], 'every post matches: no condition' );
|
||||||
|
$this->assertStringStartsWith( self::ALIAS . '.`_price` ASC', $sql['orderby'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sort_shape_with_char_type_on_a_numeric_column_stays_native(): void {
|
||||||
|
$vars = array( 'meta_query' => $this->sort_shape( 'CHAR' ), 'orderby' => 'price__order', 'order' => 'ASC' );
|
||||||
|
$sql = $this->route( $vars );
|
||||||
|
|
||||||
|
$this->assertSame( $vars['meta_query'], $sql['query']->get( 'meta_query' ) );
|
||||||
|
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_orderby_on_a_named_routed_clause_orders_by_its_column(): void {
|
||||||
|
$sql = $this->route(
|
||||||
|
array(
|
||||||
|
'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ),
|
||||||
|
'orderby' => array( 'price' => 'DESC' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringContainsString( self::ALIAS . '.`_price` IS NOT NULL', $sql['where'] );
|
||||||
|
$this->assertStringStartsWith( self::ALIAS . '.`_price` DESC', $sql['orderby'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_orderby_naming_a_routed_clause_among_other_keys_stays_native(): void {
|
||||||
|
$vars = array(
|
||||||
|
'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ),
|
||||||
|
'orderby' => array( 'price' => 'DESC', 'title' => 'ASC' ),
|
||||||
|
);
|
||||||
|
$sql = $this->route( $vars );
|
||||||
|
|
||||||
|
$this->assertSame( $vars['meta_query'], $sql['query']->get( 'meta_query' ) );
|
||||||
|
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_meta_value_num_on_a_numeric_key_requires_the_key_and_orders_by_its_column(): void {
|
||||||
|
$sql = $this->route( array( 'meta_key' => '_price', 'orderby' => 'meta_value_num', 'order' => 'DESC' ) );
|
||||||
|
|
||||||
|
$this->assertSame( '', $sql['query']->get( 'meta_key' ), 'primary meta clause taken over' );
|
||||||
|
$this->assertStringContainsString( self::ALIAS . '.`_price` IS NOT NULL', $sql['where'], 'native INNER JOIN drops posts without the key' );
|
||||||
|
$this->assertStringStartsWith( self::ALIAS . '.`_price` DESC', $sql['orderby'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_meta_value_on_a_string_key_is_routed(): void {
|
||||||
|
$sql = $this->route( array( 'meta_key' => '_sku', 'orderby' => 'meta_value', 'order' => 'ASC' ) );
|
||||||
|
|
||||||
|
$this->assertStringStartsWith( self::ALIAS . '.`_sku` ASC', $sql['orderby'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_meta_value_on_a_numeric_key_stays_native(): void {
|
||||||
|
$sql = $this->route( array( 'meta_key' => '_price', 'orderby' => 'meta_value', 'order' => 'ASC' ) );
|
||||||
|
|
||||||
|
$this->assertSame( '_price', $sql['query']->get( 'meta_key' ), 'native string order' );
|
||||||
|
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_meta_key_with_a_meta_value_filter_stays_native(): void {
|
||||||
|
$sql = $this->route( array( 'meta_key' => '_price', 'meta_value' => '5', 'orderby' => 'meta_value_num' ) );
|
||||||
|
|
||||||
|
$this->assertSame( '_price', $sql['query']->get( 'meta_key' ) );
|
||||||
|
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
$original = $query->get( 'meta_query' );
|
$original = $query->get( 'meta_query' );
|
||||||
@@ -100,7 +100,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
$original = $query->get( 'meta_query' );
|
$original = $query->get( 'meta_query' );
|
||||||
@@ -123,7 +123,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
|
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
@@ -177,7 +177,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
$original = $query->get( 'meta_query' );
|
$original = $query->get( 'meta_query' );
|
||||||
@@ -202,7 +202,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '50' ),
|
array( 'key' => '_price', 'value' => '50', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
'wpdo_post_clauses' => array(),
|
'wpdo_post_clauses' => array(),
|
||||||
) );
|
) );
|
||||||
@@ -214,6 +214,34 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
|
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_posts_join_joins_every_routed_group(): void {
|
||||||
|
$this->set_post_mode( 'aeav_only' );
|
||||||
|
|
||||||
|
$router = new WPDO_Post_Query_Router();
|
||||||
|
$query = $this->make_query( array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array(
|
||||||
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
|
array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ),
|
||||||
|
),
|
||||||
|
) );
|
||||||
|
|
||||||
|
$router->pre_get_posts( $query );
|
||||||
|
$join = $router->posts_join( '', $query );
|
||||||
|
$where = $router->posts_where( '', $query );
|
||||||
|
|
||||||
|
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
|
||||||
|
$this->assertStringContainsString( 'wpdo_post_wp_core', $join );
|
||||||
|
$this->assertStringContainsString( '`wpdoflat_product_wp_core`.`_thumbnail_id` IS NOT NULL', $where );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_no_group_by_is_added_for_one_row_per_post_joins(): void {
|
||||||
|
unset( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] );
|
||||||
|
( new WPDO_Post_Query_Router() )->register_hooks();
|
||||||
|
|
||||||
|
$this->assertEmpty( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] ?? array() );
|
||||||
|
}
|
||||||
|
|
||||||
public function test_posts_where_appends_condition_for_routed_clause(): void {
|
public function test_posts_where_appends_condition_for_routed_clause(): void {
|
||||||
$this->set_post_mode( 'aeav_only' );
|
$this->set_post_mode( 'aeav_only' );
|
||||||
|
|
||||||
@@ -221,7 +249,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
$query = $this->make_query( array(
|
$query = $this->make_query( array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'product',
|
||||||
'meta_query' => array(
|
'meta_query' => array(
|
||||||
array( 'key' => '_price', 'value' => '99', 'compare' => '=' ),
|
array( 'key' => '_price', 'value' => '99', 'compare' => '=', 'type' => 'NUMERIC' ),
|
||||||
),
|
),
|
||||||
) );
|
) );
|
||||||
|
|
||||||
@@ -242,4 +270,145 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
|
|
||||||
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
|
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── all-or-nothing: native unless every registered clause is routable ────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run pre_get_posts in aeav_only and assert the query was left to WordPress.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $vars Query vars.
|
||||||
|
*/
|
||||||
|
private function assert_not_routed( array $vars, string $why ): void {
|
||||||
|
$this->set_post_mode( 'aeav_only' );
|
||||||
|
$query = $this->make_query( $vars );
|
||||||
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertSame( $vars['meta_query'], $query->get( 'meta_query' ), $why . ': meta_query untouched.' );
|
||||||
|
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $why . ': nothing routed.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_or_relation_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
array( 'key' => '_stock', 'value' => '1' ),
|
||||||
|
array( 'key' => '_sku', 'value' => 'x' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'OR'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_several_post_types_are_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => array( 'product', 'post' ),
|
||||||
|
'meta_query' => array( array( 'key' => '_price', 'value' => '5', 'compare' => '>=', 'type' => 'NUMERIC' ) ),
|
||||||
|
),
|
||||||
|
'several post types'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_untyped_range_on_a_numeric_column_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ) ),
|
||||||
|
),
|
||||||
|
'CHAR > on DECIMAL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_untyped_equality_on_a_decimal_column_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( array( 'key' => '_price', 'value' => '10' ) ),
|
||||||
|
),
|
||||||
|
'CHAR = on DECIMAL (native "10.00" != "10")'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_numeric_type_on_a_text_column_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( array( 'key' => '_sku', 'value' => '5', 'compare' => '>', 'type' => 'NUMERIC' ) ),
|
||||||
|
),
|
||||||
|
'NUMERIC on VARCHAR'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unknown_compare_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( array( 'key' => '_sku', 'value' => '^a', 'compare' => 'REGEXP' ) ),
|
||||||
|
),
|
||||||
|
'REGEXP'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_one_unroutable_registered_clause_keeps_the_whole_query_native(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array(
|
||||||
|
array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
|
array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'one unroutable clause'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nested_group_with_a_registered_key_is_not_routed(): void {
|
||||||
|
$this->assert_not_routed(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array(
|
||||||
|
array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
|
array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
array( 'key' => '_price', 'compare' => 'NOT EXISTS' ),
|
||||||
|
array( 'key' => '_price', 'compare' => 'EXISTS' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'nested group'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_untyped_integer_equality_on_an_integer_column_is_routed(): void {
|
||||||
|
$this->set_post_mode( 'aeav_only' );
|
||||||
|
$query = $this->make_query(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array(
|
||||||
|
array( 'key' => '_stock', 'value' => '3' ),
|
||||||
|
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
||||||
|
$this->assertCount( 1, $query->get( 'meta_query' ), 'unregistered AND clause stays native' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_like_matches_an_escaped_substring(): void {
|
||||||
|
$this->set_post_mode( 'aeav_only' );
|
||||||
|
$router = new WPDO_Post_Query_Router();
|
||||||
|
$query = $this->make_query(
|
||||||
|
array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array( array( 'key' => '_sku', 'value' => 'B1_1%', 'compare' => 'LIKE' ) ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$router->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertStringContainsString( "LIKE '%B1\\\\_1\\\\%%'", $router->posts_where( '', $query ) );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ class PostStatusSyncTest extends TestCase {
|
|||||||
TMDO_Post_Status_Sync::run();
|
TMDO_Post_Status_Sync::run();
|
||||||
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
$mode->setAccessible( true );
|
$mode->setAccessible( true );
|
||||||
$mode->setValue( null, array( 'post' => 'dual_write', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
// aeav_only: the pre-filter itself writes flat (dual_write syncs from native after the write).
|
||||||
|
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' );
|
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' );
|
||||||
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
'zone' => 'hot',
|
'zone' => 'hot',
|
||||||
'column' => 'rp_price',
|
'column' => 'rp_price',
|
||||||
'type' => 'decimal',
|
'type' => 'decimal',
|
||||||
|
'show_in_rest' => true,
|
||||||
] );
|
] );
|
||||||
$registry->register( 'integration_rest', [
|
$registry->register( 'integration_rest', [
|
||||||
'post_type' => 'restapi',
|
'post_type' => 'restapi',
|
||||||
@@ -40,11 +41,21 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
'zone' => 'hot',
|
'zone' => 'hot',
|
||||||
'column' => 'rp_featured',
|
'column' => 'rp_featured',
|
||||||
'type' => 'tinyint',
|
'type' => 'tinyint',
|
||||||
|
'show_in_rest' => true,
|
||||||
] );
|
] );
|
||||||
$registry->register( 'integration_rest', [
|
$registry->register( 'integration_rest', [
|
||||||
'post_type' => 'restapi',
|
'post_type' => 'restapi',
|
||||||
'meta_key' => 'rp_description',
|
'meta_key' => 'rp_description',
|
||||||
'zone' => 'cold',
|
'zone' => 'cold',
|
||||||
|
'show_in_rest' => true,
|
||||||
|
] );
|
||||||
|
// Registered but not public (1.1.1: fields are private by default).
|
||||||
|
$registry->register( 'integration_rest', [
|
||||||
|
'post_type' => 'restapi',
|
||||||
|
'meta_key' => 'rp_cost',
|
||||||
|
'zone' => 'hot',
|
||||||
|
'column' => 'rp_cost',
|
||||||
|
'type' => 'decimal',
|
||||||
] );
|
] );
|
||||||
|
|
||||||
// Create hot table.
|
// Create hot table.
|
||||||
@@ -53,6 +64,7 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
post_id BIGINT UNSIGNED NOT NULL,
|
post_id BIGINT UNSIGNED NOT NULL,
|
||||||
rp_price DECIMAL(10,2) DEFAULT NULL,
|
rp_price DECIMAL(10,2) DEFAULT NULL,
|
||||||
rp_featured TINYINT(1) DEFAULT NULL,
|
rp_featured TINYINT(1) DEFAULT NULL,
|
||||||
|
rp_cost DECIMAL(10,2) DEFAULT NULL,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (post_id)
|
PRIMARY KEY (post_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
@@ -91,7 +103,8 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
$featured = $i % 2;
|
$featured = $i % 2;
|
||||||
$status = $statuses[ $i ] ?? 'publish';
|
$status = $statuses[ $i ] ?? 'publish';
|
||||||
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES ($i, 'restapi', '$status')" );
|
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES ($i, 'restapi', '$status')" );
|
||||||
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" );
|
$cost = 1000 - $price;
|
||||||
|
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured, rp_cost) VALUES ($i, $price, $featured, $cost)" );
|
||||||
}
|
}
|
||||||
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES (9, 'post', 'publish')" );
|
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES (9, 'post', 'publish')" );
|
||||||
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES (9, 900, 1)" );
|
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES (9, 900, 1)" );
|
||||||
@@ -194,6 +207,33 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
$this->assertTrue( true );
|
$this->assertTrue( true );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_listings_never_return_a_private_column(): void {
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'restapi' );
|
||||||
|
|
||||||
|
foreach ( self::$api->get_listings( $req )->get_data() as $item ) {
|
||||||
|
$this->assertArrayNotHasKey( 'rp_cost', $item );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_private_column_cannot_be_used_as_a_filter(): void {
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'restapi' );
|
||||||
|
$req->set_param( 'rp_cost_min', 800 );
|
||||||
|
|
||||||
|
$this->assertSame( '5', self::$api->get_listings( $req )->get_headers()['X-WP-Total'], 'filter on a private column is ignored' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_private_column_cannot_be_used_for_ordering(): void {
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'restapi' );
|
||||||
|
$req->set_param( 'orderby', 'rp_cost' );
|
||||||
|
$req->set_param( 'order', 'ASC' );
|
||||||
|
|
||||||
|
// rp_cost descends as post_id rises; ignoring it falls back to post_id ASC.
|
||||||
|
$this->assertSame( array( 1, 2, 3, 4, 5 ), array_column( self::$api->get_listings( $req )->get_data(), 'id' ) );
|
||||||
|
}
|
||||||
|
|
||||||
public function test_listings_filter_price_min(): void {
|
public function test_listings_filter_price_min(): void {
|
||||||
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
$req->set_param( 'post_type', 'restapi' );
|
$req->set_param( 'post_type', 'restapi' );
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key,
|
|||||||
|
|
||||||
if ( ! class_exists( 'WP_Query' ) ) {
|
if ( ! class_exists( 'WP_Query' ) ) {
|
||||||
class WP_Query {
|
class WP_Query {
|
||||||
|
public string $request = '';
|
||||||
private array $vars = [];
|
private array $vars = [];
|
||||||
public function get( string $key, $default = '' ) { return $this->vars[ $key ] ?? $default; }
|
public function get( string $key, $default = '' ) { return $this->vars[ $key ] ?? $default; }
|
||||||
public function set( string $key, $value ): void { $this->vars[ $key ] = $value; }
|
public function set( string $key, $value ): void { $this->vars[ $key ] = $value; }
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ Exit code 0 when every test passed, 1 otherwise. Output per test:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) |
|
| `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) |
|
||||||
| `serialized` | array meta values are stored serialized in flat tables, never "Array" |
|
| `serialized` | array meta values are stored serialized in flat tables, never "Array" |
|
||||||
|
| `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) |
|
||||||
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
|
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
|
||||||
|
|
||||||
## Tools (`tools/`)
|
## Tools (`tools/`)
|
||||||
|
|||||||
@@ -113,6 +113,29 @@ final class TMDO_Live_Fixtures {
|
|||||||
return (int) $id;
|
return (int) $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a throwaway comment (on a throwaway post of $post_type).
|
||||||
|
*
|
||||||
|
* @param string $post_type Post type of the parent post.
|
||||||
|
* @param string $comment_type Comment type.
|
||||||
|
*/
|
||||||
|
public static function comment( string $post_type, string $comment_type = 'comment' ): int {
|
||||||
|
$post_id = self::post( $post_type );
|
||||||
|
$id = wp_insert_comment(
|
||||||
|
array(
|
||||||
|
'comment_post_ID' => $post_id,
|
||||||
|
'comment_content' => 'tmdo-live',
|
||||||
|
'comment_type' => $comment_type,
|
||||||
|
'comment_approved' => 1,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( ! $id ) {
|
||||||
|
throw new TMDO_Live_Failure( 'fixture comment could not be created' );
|
||||||
|
}
|
||||||
|
self::$created[] = array( 'comment', (int) $id );
|
||||||
|
return (int) $id;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Native meta values for a key, read with SQL (no filters, no cache).
|
* Native meta values for a key, read with SQL (no filters, no cache).
|
||||||
*
|
*
|
||||||
@@ -191,6 +214,8 @@ final class TMDO_Live_Fixtures {
|
|||||||
wp_delete_term( $c[1], $c[2] );
|
wp_delete_term( $c[1], $c[2] );
|
||||||
} elseif ( 'post' === $c[0] ) {
|
} elseif ( 'post' === $c[0] ) {
|
||||||
wp_delete_post( $c[1], true );
|
wp_delete_post( $c[1], true );
|
||||||
|
} elseif ( 'comment' === $c[0] ) {
|
||||||
|
wp_delete_comment( $c[1], true );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self::$created = array();
|
self::$created = array();
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Suite: the public listings endpoints never return private fields to anonymous users.
|
||||||
|
*
|
||||||
|
* Bug (dev36, 2026-09-26, fixed in 1.1.1): GET /wpdo/v1/listings/{id} is public and
|
||||||
|
* returned every registered hot / cold field, because show_in_rest defaulted to true —
|
||||||
|
* e.g. the infocards IG token fields on hp_vendor and WooCommerce's _purchase_note.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET a wpdo REST route as an anonymous visitor.
|
||||||
|
*
|
||||||
|
* @param string $route Route, e.g. /wpdo/v1/listings/12.
|
||||||
|
* @param array<string, mixed> $params Query parameters.
|
||||||
|
* @return array<int|string, mixed> Response data.
|
||||||
|
*/
|
||||||
|
function tmdo_live_rest_anonymous( string $route, array $params = array() ): array {
|
||||||
|
$user = get_current_user_id();
|
||||||
|
$request = new WP_REST_Request( 'GET', $route );
|
||||||
|
$request->set_query_params( $params );
|
||||||
|
wp_set_current_user( 0 );
|
||||||
|
try {
|
||||||
|
$response = rest_do_request( $request );
|
||||||
|
} finally {
|
||||||
|
wp_set_current_user( $user );
|
||||||
|
}
|
||||||
|
tmdo_live_assert_same( 200, $response->get_status(), "GET {$route}" );
|
||||||
|
return (array) $response->get_data();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys that must never reach an anonymous visitor.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
function tmdo_live_rest_private_keys(): array {
|
||||||
|
return array( 'tmeetic_ig_access_token_enc', 'tmeetic_ig_token_expiry', 'tmeetic_ig_user_id', '_purchase_note' );
|
||||||
|
}
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'GET /wpdo/v1/listings/{id} returns no private field to an anonymous visitor',
|
||||||
|
static function () {
|
||||||
|
$checked = 0;
|
||||||
|
foreach ( array( 'hp_vendor', 'product' ) as $post_type ) {
|
||||||
|
$ids = get_posts( array( 'post_type' => $post_type, 'post_status' => 'publish', 'numberposts' => 1, 'fields' => 'ids' ) );
|
||||||
|
if ( ! $ids ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$data = tmdo_live_rest_anonymous( '/wpdo/v1/listings/' . (int) $ids[0] );
|
||||||
|
tmdo_live_assert_same( array(), array_values( array_intersect( tmdo_live_rest_private_keys(), array_keys( $data ) ) ), "{$post_type} #{$ids[0]} private keys" );
|
||||||
|
tmdo_live_assert_same( array(), array_values( preg_grep( '/^_|token|secret|passw|api_?key/i', array_keys( $data ) ) ), "{$post_type} #{$ids[0]} protected / credential-like keys" );
|
||||||
|
++$checked;
|
||||||
|
}
|
||||||
|
if ( 0 === $checked ) {
|
||||||
|
throw new TMDO_Live_Skip( 'no published hp_vendor or product on this site' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'GET /wpdo/v1/listings returns no private field to an anonymous visitor',
|
||||||
|
static function () {
|
||||||
|
foreach ( array( 'hp_vendor', 'product', 'hp_listing' ) as $post_type ) {
|
||||||
|
if ( ! post_type_exists( $post_type ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ( tmdo_live_rest_anonymous( '/wpdo/v1/listings', array( 'post_type' => $post_type, 'per_page' => 5 ) ) as $item ) {
|
||||||
|
tmdo_live_assert_same( array(), array_values( preg_grep( '/^_|token|secret|passw|api_?key/i', array_keys( (array) $item ) ) ), "{$post_type} list item keys" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Suite: in dual_write / shadow_read the flat value always follows native meta.
|
||||||
|
*
|
||||||
|
* Before PR-057 the Hook Bus wrote flat in the pre-filter, before WP decided
|
||||||
|
* whether native changed at all: a non-matching prev_value, add($unique) on an
|
||||||
|
* existing key or a key change via update_metadata_by_mid left flat and native
|
||||||
|
* apart, and nothing healed a flat value made stale by a direct SQL write.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity cases: [entity type, key, fixture factory, post type for group lookup].
|
||||||
|
*
|
||||||
|
* @return array<string, array{0:string, 1:string, 2:callable, 3:string}>
|
||||||
|
*/
|
||||||
|
function tmdo_live_resync_cases(): array {
|
||||||
|
return array(
|
||||||
|
'user first_name' => array( 'user', 'first_name', array( 'TMDO_Live_Fixtures', 'user' ), '' ),
|
||||||
|
'hp_listing price' => array( 'post', 'hp_price', static fn() => TMDO_Live_Fixtures::post( 'hp_listing' ), 'hp_listing' ),
|
||||||
|
'hp term icon' => array( 'term', 'hp_icon', static fn() => TMDO_Live_Fixtures::term( 'hp_listing_category' ), '' ),
|
||||||
|
'hp review rating' => array( 'comment', 'hp_rating', static fn() => TMDO_Live_Fixtures::comment( 'hp_listing', 'hp_review' ), '' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert flat equals the first native value (numeric-aware; missing native = NULL flat).
|
||||||
|
*/
|
||||||
|
function tmdo_live_assert_in_step( string $type, int $id, string $key, string $post_type, string $context ): void {
|
||||||
|
$native = TMDO_Live_Fixtures::native( $type, $id, $key );
|
||||||
|
$flat = TMDO_Live_Fixtures::flat( $type, $id, $key, $post_type );
|
||||||
|
$expect = $native ? $native[0] : null;
|
||||||
|
if ( 'NO-ROW' === $flat ) {
|
||||||
|
$flat = null;
|
||||||
|
}
|
||||||
|
$same = ( null === $expect || null === $flat )
|
||||||
|
? $expect === $flat
|
||||||
|
: ( is_numeric( $expect ) && is_numeric( $flat ) ? (float) $expect === (float) $flat : $expect === $flat );
|
||||||
|
tmdo_live_assert_true( $same, sprintf( '%s — native %s, flat %s', $context, var_export( $expect, true ), var_export( $flat, true ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( array( 'dual_write', 'shadow_read' ) as $tmdo_live_mode ) {
|
||||||
|
foreach ( tmdo_live_resync_cases() as $tmdo_live_label => $tmdo_live_case ) {
|
||||||
|
list( $type, $key, $make, $pt ) = $tmdo_live_case;
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
"{$tmdo_live_mode} {$tmdo_live_label}: update, same value, delete",
|
||||||
|
static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) {
|
||||||
|
TMDO_Live_Fixtures::field( $type, $key, $pt );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) );
|
||||||
|
$id = $make();
|
||||||
|
|
||||||
|
update_metadata( $type, $id, $key, '11' );
|
||||||
|
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'new key' );
|
||||||
|
update_metadata( $type, $id, $key, '12' );
|
||||||
|
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'update' );
|
||||||
|
update_metadata( $type, $id, $key, '12' );
|
||||||
|
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'same value' );
|
||||||
|
delete_metadata( $type, $id, $key, '12' );
|
||||||
|
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'delete with value' );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
"{$tmdo_live_mode} {$tmdo_live_label}: writes WP refuses leave flat alone",
|
||||||
|
static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) {
|
||||||
|
TMDO_Live_Fixtures::field( $type, $key, $pt );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) );
|
||||||
|
$id = $make();
|
||||||
|
update_metadata( $type, $id, $key, '21' );
|
||||||
|
|
||||||
|
update_metadata( $type, $id, $key, '22', 'no-such-previous-value' );
|
||||||
|
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'non-matching prev_value' );
|
||||||
|
add_metadata( $type, $id, $key, '23', true );
|
||||||
|
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'add unique on existing key' );
|
||||||
|
update_metadata( $type, $id, $key, '24', '21' );
|
||||||
|
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'matching prev_value' );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
"{$tmdo_live_mode} {$tmdo_live_label}: stale flat heals on the next write, key change by meta_id",
|
||||||
|
static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) {
|
||||||
|
global $wpdb;
|
||||||
|
TMDO_Live_Fixtures::field( $type, $key, $pt );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) );
|
||||||
|
$id = $make();
|
||||||
|
update_metadata( $type, $id, $key, '31' );
|
||||||
|
|
||||||
|
$table = _get_meta_table( $type );
|
||||||
|
$id_col = $type . '_id';
|
||||||
|
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||||
|
|
||||||
|
// A direct SQL write (as WooCommerce does for stock) leaves flat stale.
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP meta table names.
|
||||||
|
$wpdb->query( $wpdb->prepare( "UPDATE {$table} SET meta_value = '32' WHERE {$id_col} = %d AND meta_key = %s", $id, $key ) );
|
||||||
|
wp_cache_delete( $id, $type . '_meta' );
|
||||||
|
update_metadata( $type, $id, $key, '33' );
|
||||||
|
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'next write after a direct SQL change' );
|
||||||
|
|
||||||
|
// Renaming the key by meta_id removes it from native: flat must follow.
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP meta table names.
|
||||||
|
$mid = (int) $wpdb->get_var( $wpdb->prepare( "SELECT {$pk} FROM {$table} WHERE {$id_col} = %d AND meta_key = %s", $id, $key ) );
|
||||||
|
update_metadata_by_mid( $type, $mid, '34', 'tmdo_live_renamed' );
|
||||||
|
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'key renamed by meta_id' );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'deleting an entity leaves no flat row behind (dual_write and shadow_read)',
|
||||||
|
static function () {
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||||||
|
$deleters = array(
|
||||||
|
'user' => static fn( int $id ) => wp_delete_user( $id ),
|
||||||
|
'post' => static fn( int $id ) => wp_delete_post( $id, true ),
|
||||||
|
'term' => static fn( int $id ) => wp_delete_term( $id, 'hp_listing_category' ),
|
||||||
|
'comment' => static fn( int $id ) => wp_delete_comment( $id, true ),
|
||||||
|
);
|
||||||
|
foreach ( array( 'dual_write', 'shadow_read' ) as $mode ) {
|
||||||
|
foreach ( tmdo_live_resync_cases() as $label => $case ) {
|
||||||
|
list( $type, $key, $make, $pt ) = $case;
|
||||||
|
TMDO_Live_Fixtures::field( $type, $key, $pt );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( $type => $mode ) );
|
||||||
|
$id = $make();
|
||||||
|
update_metadata( $type, $id, $key, '41' );
|
||||||
|
|
||||||
|
$deleters[ $type ]( $id );
|
||||||
|
|
||||||
|
tmdo_live_assert_same( 'NO-ROW', TMDO_Live_Fixtures::flat( $type, $id, $key, $pt ), "{$mode} {$label}: flat row after delete" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Suite: a routed WP_Query returns exactly what the native meta_query returns.
|
||||||
|
*
|
||||||
|
* Bugs (dev36 qbench, 2026-09-26): the post router pulled registered clauses
|
||||||
|
* out of any meta_query and ANDed them, so OR relations (Q04, Q16), queries on
|
||||||
|
* several post types (Q08), untyped comparisons on numeric columns (Q14,
|
||||||
|
* native compares strings) and orderby on a named clause (Q15) returned other
|
||||||
|
* posts than WordPress would; LIKE compared without wildcards (Q06). A key
|
||||||
|
* of a second group (wp_core _thumbnail_id, Q05) was never joined, and every
|
||||||
|
* routed query carried a GROUP BY although flat rows are one per post.
|
||||||
|
*
|
||||||
|
* Every case runs the same query twice on fixture posts — router hooks off,
|
||||||
|
* then on with post mode shadow_read — and compares the ids in order.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a WP_Query restricted to $ids, optionally through the post router.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $args Query args (meta_query, post_type, orderby …).
|
||||||
|
* @param int[] $ids Fixture posts to search within.
|
||||||
|
* @param bool $routed Hook a TMDO_Post_Query_Router for this query.
|
||||||
|
* @return array{ids:int[], routed:bool, sql:string}
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_query( array $args, array $ids, bool $routed ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
if ( $routed ) {
|
||||||
|
$router->register_hooks();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$wpdb->last_error = '';
|
||||||
|
$q = new WP_Query(
|
||||||
|
$args + array(
|
||||||
|
'post__in' => $ids,
|
||||||
|
'post_status' => 'any',
|
||||||
|
'posts_per_page' => -1,
|
||||||
|
'orderby' => 'ID',
|
||||||
|
'order' => 'ASC',
|
||||||
|
'fields' => 'ids',
|
||||||
|
'no_found_rows' => true,
|
||||||
|
'cache_results' => false,
|
||||||
|
'suppress_filters' => false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
tmdo_live_router_unhook( $router );
|
||||||
|
}
|
||||||
|
tmdo_live_assert_same( '', $wpdb->last_error, 'SQL error' );
|
||||||
|
return array(
|
||||||
|
'ids' => array_map( 'intval', $q->posts ),
|
||||||
|
'routed' => ! empty( $q->get( 'wpdo_post_clauses' ) ) || ! empty( $q->get( 'wpdo_post_sort' ) ),
|
||||||
|
'sql' => (string) $q->request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove every hook TMDO_Post_Query_Router::register_hooks() added.
|
||||||
|
*
|
||||||
|
* @param TMDO_Post_Query_Router $router Router instance.
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_unhook( TMDO_Post_Query_Router $router ): void {
|
||||||
|
foreach ( array( 'pre_get_posts', 'posts_join', 'posts_where', 'posts_orderby', 'posts_request', 'split_the_query', 'found_posts_query' ) as $method ) {
|
||||||
|
foreach ( 'posts_request' === $method ? array( 'posts_request', 'posts_request_ids' ) : array( $method ) as $hook ) {
|
||||||
|
$priority = has_filter( $hook, array( $router, $method ) );
|
||||||
|
if ( false !== $priority ) {
|
||||||
|
remove_filter( $hook, array( $router, $method ), $priority );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create posts of $post_type with the given meta, in post mode shadow_read
|
||||||
|
* (so the Hook Bus keeps the flat rows in step).
|
||||||
|
*
|
||||||
|
* @param string $post_type Post type.
|
||||||
|
* @param array<string, array<string, mixed>> $posts Label => meta.
|
||||||
|
* @return array<string, int> Label => post id.
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_posts( string $post_type, array $posts ): array {
|
||||||
|
$ids = array();
|
||||||
|
foreach ( $posts as $label => $meta ) {
|
||||||
|
$ids[ $label ] = TMDO_Live_Fixtures::post( $post_type );
|
||||||
|
foreach ( $meta as $key => $value ) {
|
||||||
|
update_post_meta( $ids[ $label ], $key, $value );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listing fixtures shared by the hp_listing cases.
|
||||||
|
*
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_listings(): array {
|
||||||
|
foreach ( array( 'hp_price', 'hp_verified', 'hp_featured', 'hp_hourly_rate' ) as $key ) {
|
||||||
|
TMDO_Live_Fixtures::field( 'post', $key, 'hp_listing' );
|
||||||
|
}
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
|
return tmdo_live_router_posts(
|
||||||
|
'hp_listing',
|
||||||
|
array(
|
||||||
|
'L1' => array( 'hp_price' => '10000', 'hp_featured' => '1', 'hp_verified' => '0', 'hp_hourly_rate' => '40' ),
|
||||||
|
'L2' => array( 'hp_price' => '9500', 'hp_verified' => '1', 'hp_hourly_rate' => '30' ),
|
||||||
|
'L3' => array( 'hp_price' => '500', 'hp_featured' => '0', 'hp_hourly_rate' => '45' ),
|
||||||
|
'L4' => array( 'hp_price' => '20000', 'tmdo_live_unregistered' => '0' ),
|
||||||
|
'L5' => array(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert the routed query equals native; optionally that it was routed at all.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $args Query args.
|
||||||
|
* @param int[] $ids Fixture ids.
|
||||||
|
* @param bool|null $expect_routed true / false to assert, null to ignore.
|
||||||
|
* @param bool $ordered Compare order too (false when the order has ties).
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_assert_native( array $args, array $ids, ?bool $expect_routed = null, bool $ordered = true ): void {
|
||||||
|
$native = tmdo_live_router_query( $args, $ids, false );
|
||||||
|
$routed = tmdo_live_router_query( $args, $ids, true );
|
||||||
|
if ( ! $ordered ) {
|
||||||
|
sort( $native['ids'] );
|
||||||
|
sort( $routed['ids'] );
|
||||||
|
}
|
||||||
|
tmdo_live_assert_same( $native['ids'], $routed['ids'], 'routed ids vs native' );
|
||||||
|
if ( null !== $expect_routed ) {
|
||||||
|
tmdo_live_assert_same( $expect_routed, $routed['routed'], 'query routed to the flat table' );
|
||||||
|
}
|
||||||
|
if ( $routed['routed'] ) {
|
||||||
|
tmdo_live_assert_true( false === stripos( $routed['sql'], 'GROUP BY' ), 'routed query adds no GROUP BY: ' . $routed['sql'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q04 OR across registered keys returns the native posts',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
array( 'key' => 'hp_featured', 'value' => '1' ),
|
||||||
|
array( 'key' => 'hp_verified', 'value' => '1' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
array_values( $l )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q16 OR mixing a registered and an unregistered key returns the native posts',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
array( 'key' => 'hp_price', 'value' => 1000, 'compare' => '<', 'type' => 'NUMERIC' ),
|
||||||
|
array( 'key' => 'tmdo_live_unregistered', 'value' => '0' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
array_values( $l )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q14 untyped comparison on a numeric column keeps string semantics',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array( array( 'key' => 'hp_price', 'value' => '9000', 'compare' => '>' ) ),
|
||||||
|
),
|
||||||
|
array_values( $l )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q15 orderby on a named clause keeps the native order, routed',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array(
|
||||||
|
'rate' => array( 'key' => 'hp_hourly_rate', 'value' => 50, 'compare' => '<', 'type' => 'NUMERIC' ),
|
||||||
|
'price' => array( 'key' => 'hp_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ),
|
||||||
|
),
|
||||||
|
'orderby' => 'price',
|
||||||
|
'order' => 'ASC',
|
||||||
|
),
|
||||||
|
array_values( $l ),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q08 several post types return the native posts',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
TMDO_Live_Fixtures::field( 'post', 'hp_budget', 'hp_request' );
|
||||||
|
$r = tmdo_live_router_posts( 'hp_request', array( 'R1' => array( 'hp_budget' => '90000' ) ) );
|
||||||
|
update_post_meta( $l['L2'], 'hp_budget', '85000' );
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => array( 'hp_listing', 'hp_request' ),
|
||||||
|
'meta_query' => array( array( 'key' => 'hp_budget', 'value' => 80000, 'compare' => '>=', 'type' => 'NUMERIC' ) ),
|
||||||
|
),
|
||||||
|
array_merge( array_values( $l ), array_values( $r ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'typed AND on registered keys is routed and returns the native posts',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array(
|
||||||
|
array( 'key' => 'hp_price', 'value' => array( 9000, 15000 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
|
||||||
|
array( 'key' => 'hp_verified', 'value' => '1' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
array_values( $l ),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q06 LIKE matches substrings and escapes wildcards, routed',
|
||||||
|
static function () {
|
||||||
|
TMDO_Live_Fixtures::field( 'post', '_sku', 'product' );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
|
$p = tmdo_live_router_posts(
|
||||||
|
'product',
|
||||||
|
array(
|
||||||
|
'P1' => array( '_sku' => 'tmdo-live-B1-1' ),
|
||||||
|
'P2' => array( '_sku' => 'tmdo-live-B1-10' ),
|
||||||
|
'P3' => array( '_sku' => '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' ) ),
|
||||||
|
),
|
||||||
|
array_values( $p ),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q05 keys of two groups are both joined, routed',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
TMDO_Live_Fixtures::field( 'post', '_thumbnail_id', 'hp_listing' );
|
||||||
|
update_post_meta( $l['L1'], '_thumbnail_id', '123' );
|
||||||
|
update_post_meta( $l['L3'], '_thumbnail_id', '124' );
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array(
|
||||||
|
array( 'key' => 'hp_hourly_rate', 'value' => 42, 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
|
array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
array_values( $l ),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'NOT EXISTS with explicit statuses and date order keeps posts without a flat row',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'post_status' => array( 'draft' ),
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
'meta_query' => array( array( 'key' => 'hp_featured', 'compare' => 'NOT EXISTS' ) ),
|
||||||
|
),
|
||||||
|
array_values( $l ),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'HivePress sort shape (NOT EXISTS OR EXISTS, named clause) keeps the native order, routed',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
foreach ( array( 'ASC', 'DESC' ) as $order ) {
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_query' => array(
|
||||||
|
array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
'price__order' => array( 'key' => 'hp_price', 'type' => 'DECIMAL', 'compare' => 'NOT EXISTS' ),
|
||||||
|
array( 'key' => 'hp_price', 'type' => 'DECIMAL', 'compare' => 'EXISTS' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'orderby' => 'price__order',
|
||||||
|
'order' => $order,
|
||||||
|
),
|
||||||
|
array_values( $l ),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'Q07 meta_key + meta_value_num keeps the native posts and order, routed',
|
||||||
|
static function () {
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
tmdo_live_router_assert_native(
|
||||||
|
array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'meta_key' => 'hp_price',
|
||||||
|
'orderby' => 'meta_value_num',
|
||||||
|
'order' => 'DESC',
|
||||||
|
),
|
||||||
|
array_values( $l ),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'a routed full-row search keeps the split query (IDs first) and native found_posts',
|
||||||
|
static function () {
|
||||||
|
global $wpdb;
|
||||||
|
$l = tmdo_live_router_listings();
|
||||||
|
$args = array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'post__in' => array_values( $l ),
|
||||||
|
'post_status' => 'any',
|
||||||
|
'posts_per_page' => 2,
|
||||||
|
'orderby' => 'ID',
|
||||||
|
'order' => 'ASC',
|
||||||
|
'meta_query' => array( array( 'key' => 'hp_price', 'value' => 1000, 'compare' => '>=', 'type' => 'NUMERIC' ) ),
|
||||||
|
);
|
||||||
|
$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 );
|
||||||
|
}
|
||||||
|
|
||||||
|
tmdo_live_assert_same( wp_list_pluck( $native->posts, 'ID' ), wp_list_pluck( $routed->posts, 'ID' ), 'page of posts' );
|
||||||
|
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
|
||||||
|
tmdo_live_assert_true( false !== strpos( $routed->request, 'wpdoflat_' ), 'routed: ' . $routed->request );
|
||||||
|
tmdo_live_assert_true( 1 === preg_match( '/^\s*SELECT\s+' . preg_quote( $wpdb->posts, '/' ) . '\.ID\s/', $routed->request ), 'IDs first: ' . $routed->request );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'a filtered search ordered by a flat column drives from the flat table and matches native',
|
||||||
|
static function () {
|
||||||
|
foreach ( array( 'hp_price', 'hp_hourly_rate' ) as $key ) {
|
||||||
|
TMDO_Live_Fixtures::field( 'post', $key, 'hp_listing' );
|
||||||
|
}
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
|
// A price band no seeded listing uses keeps the result to the fixtures.
|
||||||
|
tmdo_live_router_posts(
|
||||||
|
'hp_listing',
|
||||||
|
array(
|
||||||
|
'A' => array( 'hp_price' => '9876501', 'hp_hourly_rate' => '12' ),
|
||||||
|
'B' => array( 'hp_price' => '9876502', 'hp_hourly_rate' => '15' ),
|
||||||
|
'C' => array( 'hp_price' => '9876503' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$args = array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'post_status' => 'any',
|
||||||
|
'posts_per_page' => 10,
|
||||||
|
'fields' => 'ids',
|
||||||
|
'suppress_filters' => false,
|
||||||
|
'meta_query' => array( array( 'key' => 'hp_price', 'value' => array( 9876500, 9876510 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
|
||||||
|
'meta_key' => 'hp_price',
|
||||||
|
'orderby' => 'meta_value_num',
|
||||||
|
'order' => 'DESC',
|
||||||
|
);
|
||||||
|
$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 );
|
||||||
|
}
|
||||||
|
|
||||||
|
tmdo_live_assert_same( array_map( 'intval', $native->posts ), array_map( 'intval', $routed->posts ), 'ids' );
|
||||||
|
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 );
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -95,11 +95,13 @@ class RestApiTest extends TestCase {
|
|||||||
'zone' => 'hot',
|
'zone' => 'hot',
|
||||||
'column' => 'hp_price',
|
'column' => 'hp_price',
|
||||||
'type' => 'decimal',
|
'type' => 'decimal',
|
||||||
|
'show_in_rest' => true,
|
||||||
] );
|
] );
|
||||||
$registry->register( 'test', [
|
$registry->register( 'test', [
|
||||||
'post_type' => 'hp_listing',
|
'post_type' => 'hp_listing',
|
||||||
'meta_key' => 'hp_description',
|
'meta_key' => 'hp_description',
|
||||||
'zone' => 'cold',
|
'zone' => 'cold',
|
||||||
|
'show_in_rest' => true,
|
||||||
] );
|
] );
|
||||||
|
|
||||||
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' );
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' );
|
||||||
@@ -115,6 +117,62 @@ class RestApiTest extends TestCase {
|
|||||||
$this->assertSame( 'Test desc', $data['hp_description'] );
|
$this->assertSame( 'Test desc', $data['hp_description'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── public field exposure (1.1.1) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register fields on hp_listing #43 and GET it anonymously.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $fields Field configs (post_type added).
|
||||||
|
* @param array<string, string> $meta Stored meta.
|
||||||
|
* @return array<string, mixed> Response data.
|
||||||
|
*/
|
||||||
|
private function listing_with( array $fields, array $meta ): array {
|
||||||
|
$GLOBALS['_wp_post_types'][43] = 'hp_listing';
|
||||||
|
$GLOBALS['_wp_postmeta'][43] = $meta;
|
||||||
|
foreach ( $fields as $field ) {
|
||||||
|
WPDO_Schema_Registry::instance()->register( 'test', $field + array( 'post_type' => 'hp_listing' ) );
|
||||||
|
}
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/43' );
|
||||||
|
$req->set_param( 'id', 43 );
|
||||||
|
return $this->api->get_listing( $req )->get_data();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_registered_fields_are_not_public_unless_declared(): void {
|
||||||
|
$data = $this->listing_with(
|
||||||
|
array(
|
||||||
|
array( 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price' ),
|
||||||
|
array( 'meta_key' => 'hp_notes', 'zone' => 'cold' ),
|
||||||
|
),
|
||||||
|
array( 'hp_price' => '500', 'hp_notes' => 'internal' )
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame( array( 'id', 'post_type' ), array_keys( $data ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_protected_and_credential_keys_stay_private_even_when_declared_public(): void {
|
||||||
|
$data = $this->listing_with(
|
||||||
|
array(
|
||||||
|
array( 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ),
|
||||||
|
array( 'meta_key' => '_purchase_note', 'zone' => 'cold', 'show_in_rest' => true ),
|
||||||
|
array( 'meta_key' => 'tmeetic_ig_access_token_enc', 'zone' => 'cold', 'show_in_rest' => true ),
|
||||||
|
array( 'meta_key' => 'hp_api_key', 'zone' => 'hot', 'column' => 'hp_api_key', 'show_in_rest' => true ),
|
||||||
|
array( 'meta_key' => 'hp_client_secret', 'zone' => 'cold', 'show_in_rest' => true ),
|
||||||
|
),
|
||||||
|
array( 'hp_price' => '500', '_purchase_note' => 'x', 'tmeetic_ig_access_token_enc' => 'y', 'hp_api_key' => 'z', 'hp_client_secret' => 'w' )
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame( array( 'id', 'post_type', 'hp_price' ), 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 ) );
|
||||||
|
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => '_hp_token', 'zone' => 'hot', 'column' => 'hp_token', 'show_in_rest' => true ) );
|
||||||
|
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_draft', 'zone' => 'hot', 'column' => 'hp_draft' ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'hp_price' ), $registry->get_rest_visible_hot_columns( 'hp_listing' ) );
|
||||||
|
}
|
||||||
|
|
||||||
// ── get_stats ─────────────────────────────────────────────────────────────
|
// ── get_stats ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function test_get_stats_404_when_post_not_found(): void {
|
public function test_get_stats_404_when_post_not_found(): void {
|
||||||
@@ -185,7 +243,7 @@ class RestApiTest extends TestCase {
|
|||||||
|
|
||||||
public function test_listings_fallback_only_returns_show_in_rest_columns(): void {
|
public function test_listings_fallback_only_returns_show_in_rest_columns(): void {
|
||||||
$registry = WPDO_Schema_Registry::instance();
|
$registry = WPDO_Schema_Registry::instance();
|
||||||
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price' ) );
|
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) );
|
||||||
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_secret', 'zone' => 'hot', 'column' => 'hp_secret', 'show_in_rest' => false ) );
|
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_secret', 'zone' => 'hot', 'column' => 'hp_secret', 'show_in_rest' => false ) );
|
||||||
|
|
||||||
$post = new stdClass();
|
$post = new stdClass();
|
||||||
|
|||||||
Reference in New Issue
Block a user