27 Commits

Author SHA1 Message Date
wpdev 0c15266034 Merge pull request 'release 1.4.3: 2meet-data-optimizer' (#12) from release/1.4.3 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 12s
Tests / PHP Lint (push) Successful in 26s
Tests / Unit Tests (push) Successful in 49s
Tests / Integration Tests (push) Successful in 2m2s
Tests / PHPCS (push) Successful in 58s
Tests / PHPStan (push) Successful in 58s
2026-10-02 21:45:01 +08:00
wpdev c13691bcea chore(2meet-data-optimizer): 1.4.3 repackage (no functional change)
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 16s
Tests / Unit Tests (pull_request) Successful in 42s
Tests / PHPCS (pull_request) Successful in 41s
Tests / Integration Tests (pull_request) Successful in 1m56s
Tests / PHPStan (pull_request) Successful in 1m1s
Tests / PHP Lint (pull_request) Successful in 8s
Version bump for a re-release packaged alongside the WP-MVB v8 hub (dev39)
acceptance; code is identical to the previous release.
2026-10-02 17:23:21 +08:00
wpdev 04ae4c0d0f Merge pull request 'Release 2.4.2: core 1.4.2 bugfix' (#11) from hardening/2.4.2 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 12s
Tests / PHP Lint (push) Successful in 14s
Tests / Unit Tests (push) Successful in 47s
Tests / Integration Tests (push) Successful in 1m58s
Tests / PHPCS (push) Successful in 49s
Tests / PHPStan (push) Successful in 50s
2026-09-28 18:51:39 +08:00
wpdev 6f117f4e9b fix(2meet-data-optimizer): 1.4.2 acceptance fixes for archive sweep and uninstall
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 21s
Tests / PHP Lint (pull_request) Successful in 18s
Tests / Unit Tests (pull_request) Successful in 41s
Tests / Integration Tests (pull_request) Successful in 1m25s
Tests / PHPCS (pull_request) Successful in 44s
Tests / PHPStan (pull_request) Successful in 47s
- Archive sweep waits until idx_original_meta exists (a plugin update does
  not run dbDelta; without the index NOT EXISTS scans the archive per row)
- Duplicate archive cleanup runs from WP-CLI only, never in a web activation
- Uninstall keeps the LatePoint add-on's tmdo_latepoint_db_version
- AJAX save errors stay on screen instead of fading after 2.5 s
- Verify: 2 new integration tests; unit 711, integration 612, phpcs 0, stan OK
- PR: PR-141 (2.4.2 correctness review)
2026-09-28 18:43:50 +08:00
wpdev c9d5a08a42 chore(2meet-data-optimizer): 1.4.2 release notes and version
- Version 1.4.2 (header, TMDO_VERSION, readme Stable tag and changelog)
- CHANGELOG lists PR-135..139; after upgrade run wp tmdo install once per
  site for the archive index and duplicate cleanup
- PR: PR-140
2026-09-28 18:26:52 +08:00
wpdev cd5da4143e chore(2meet-data-optimizer): uninstall message points to bridge-restore-native; stress tester drops wc_product
- The refused-uninstall notice said no tool writes the data back to native
  tables; wp tmdo bridge-restore-native does.
- The post stress tester no longer maps product to the retired wc_product table.
- Verify: unit 711, integration 611, phpcs 0
- PR: PR-139 (manual F-7, F-8)
2026-09-28 18:26:15 +08:00
wpdev 15096f064f fix(2meet-data-optimizer): deactivate and uninstall clear every scheduled event and tmdo_* option
- One CRON_HOOKS list for deactivate() and uninstall; wp_unschedule_hook() also
  removes events scheduled with args (tmdo_drift_resync, backfill batches).
- Fix the wrong wpdo_user_stress_test_batch name; add migration tick,
  entity backfill, auto-promote and all stress-test batch hooks.
- Uninstall sweeps tmdo_* options and _transient_(timeout_)tmdo_* rows too.
- Verify: InstallerCleanupTest (3 new), unit 711, integration 611, phpcs 0
- PR: PR-138 (manual F-4, F-5)
2026-09-28 18:25:01 +08:00
wpdev 90877b81e0 fix(2meet-data-optimizer): entity health sees a running backfill
- Backfill batches are scheduled with (entity, group) args; the health
  panel looked for (entity) alone, never saw one and kept offering to start
  a backfill, which resets the group's checkpoint
- is_backfill_active() checks every group of the entity
- Unit bootstrap: wp_next_scheduled() stub honours hook + args
- Verify: EntityHealthBackfillTest (2 new); unit 711, phpcs 0, stan OK
- PR: PR-137 (manual F-11)
2026-09-28 18:18:56 +08:00
wpdev 6687d54bef fix(2meet-data-optimizer): archive sweep copies each trashed meta row once
- sweep() skips meta rows already in wpdo_archive (NOT EXISTS on
  original_meta_id): it copied the same trashed meta again every day and
  wpdo_archive has no unique key (dev20: 300 rows for 150 meta rows)
- TMDO_Zone_Archive::remove_duplicates() keeps the first copy per
  original_meta_id (rows without one are left alone); install() runs it,
  so `wp tmdo install` cleans existing sites
- wpdo_archive gets KEY idx_original_meta for the lookup (new installs;
  existing sites via `wp tmdo install`). SCHEMA_VERSION is not bumped, so
  no web request runs dbDelta
- Verify: ZoneArchiveIntegrationTest (2 new); dev20 read-only check: 150 of
  300 rows would be removed; unit 709, integration 608, phpcs 0, stan OK
- PR: PR-136 (manual F-6)
2026-09-28 18:17:47 +08:00
wpdev 86ac84e07c fix(2meet-data-optimizer): show why an admin bridge-mode change was refused
- The settings form and the AJAX section save both went through
  TMDO_Mode_Manager::set() and dropped its WP_Error: an unsafe jump or a
  Promotion Guard refusal still said "saved" while the mode stayed put
- TMDO_Admin::apply_bridge_modes() returns the refusals; the full form keeps
  them in a per-user transient and shows them after the redirect, the AJAX
  save returns them as the error message (the settings JS shows it)
- Verify: AdminBridgeModeSaveTest (5 new); unit 709, integration 606, phpcs 0, stan OK
- PR: PR-135 (manual F-2)
2026-09-28 18:14:35 +08:00
wpdev b6200c581c Merge pull request 'hardening 2.4.1: 2meet-data-optimizer 1.4.1 (acceptance review fixes)' (#10) from hardening/2.4.1 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 51s
Tests / Unit Tests (push) Successful in 47s
Tests / PHP Lint (push) Successful in 1m9s
Tests / Integration Tests (push) Successful in 2m4s
Tests / PHPCS (push) Successful in 1m15s
Tests / PHPStan (push) Successful in 1m14s
2026-09-28 01:09:15 +08:00
wpdev 81324903a6 chore(2meet-data-optimizer): 1.4.1 release notes and version
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 47s
Tests / PHP Lint (pull_request) Successful in 53s
Tests / Unit Tests (pull_request) Successful in 51s
Tests / Integration Tests (pull_request) Successful in 1m54s
Tests / PHPCS (pull_request) Successful in 36s
Tests / PHPStan (pull_request) Successful in 1m12s
- CHANGELOG / readme: PR-130..133 (2.4 acceptance review fixes)
- Version 1.4.0 -> 1.4.1 (header, TMDO_VERSION, Stable tag)
- PR: PR-134
2026-09-28 00:46:56 +08:00
wpdev 3e96357459 fix(2meet-data-optimizer): give new posts' flat rows their status/date copies; sync drops cached counts
- add_flat_rows (wp_insert_post, after transition_post_status) inserts the
  copies with the empty row: a NULL copy turned the copy route off for the
  whole group until the next sync
- wp tmdo post-status-sync bumps the routed count version (not on dry runs)
- Test bootstrap: minimal WP_Post stub
- Found in the 2.4 correctness review (residual risks)
- Verify: 2 new integration tests; integration 606, unit 704, phpcs 0, stan OK
- PR: PR-133
2026-09-28 00:32:59 +08:00
wpdev 80343ec282 fix(2meet-data-optimizer): drop router query vars a caller passed in
- pre_get_posts clears wpdo_post_clauses / _sort / _copy_route / _count_sql
  before anything else: new WP_Query( array( … ) ) could preset them
- posts_orderby whitelists the copy route's direction
- Found in the 2.4 security review (Low L2; not reachable from URLs)
- Verify: PostQueryRouterCopyTest (1 new); integration 604, unit 704, phpcs 0
- PR: PR-132
2026-09-28 00:31:10 +08:00
wpdev 33addd1e49 fix(2meet-data-optimizer): rewrite only standalone posts.ID tie-breaks in ORDER BY
- flat_first and the copy route share ties_on_post_id(): a posts.ID term
  alone (optionally with a direction) reads the flat post_id; posts.ID inside
  an expression a plugin added (FIELD(), IN) is left alone — inserting a
  direction there made the SQL invalid and the page empty
- PCRE failures return the text given instead of '' (the query kept its
  shape rather than becoming an empty request)
- Found in the 2.4 correctness review (Medium) and security review (Low L1)
- Verify: 2 new integration tests (the flat-first one runs the SQL), router suites 69 OK
- PR: PR-131
2026-09-28 00:29:53 +08:00
wpdev bed3f906da fix(2meet-data-optimizer): keep meta_key native when the query still orders by it
- meta_key is routed as EXISTS only when no orderby entry names meta_value,
  meta_value_num or the key (several orderings or an array included);
  clearing it made WordPress drop the price ordering
- A sort group is dropped as unused only when orderby names neither it,
  meta_value(_num) nor its key (it is then the primary clause)
- meta_compare_key other than '=' keeps meta_key native (LIKE matches other keys)
- Found in the 2.4 correctness review (regressions from PR-122)
- Verify: PostQueryRouterTest (3 new), router suites 67 OK
- PR: PR-130
2026-09-28 00:27:56 +08:00
wpdev 7ad65eab95 Merge pull request 'docs: correct the 1.4.0 upgrade step for the copy index' (#9) from docs/1.4.0-upgrade-note into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 11s
Tests / PHP Lint (push) Successful in 22s
Tests / Unit Tests (push) Successful in 1m12s
Tests / Integration Tests (push) Successful in 2m7s
Tests / PHPCS (push) Successful in 1m9s
Tests / PHPStan (push) Successful in 1m10s
2026-09-28 00:08:56 +08:00
wpdev 4b34851316 docs(2meet-data-optimizer): correct the 1.4.0 upgrade step for the copy index
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 25s
Tests / PHP Lint (pull_request) Successful in 55s
Tests / Unit Tests (pull_request) Successful in 56s
Tests / Integration Tests (pull_request) Successful in 1m46s
Tests / PHPCS (pull_request) Successful in 44s
Tests / PHPStan (pull_request) Successful in 1m4s
- Replace the index only on tables listed in tmdo_post_status_columns
  (ALTER ADD idx_post_status_date_id / DROP idx_post_status_date)
- Warn that `wp tmdo post-status-sync` also adds and backfills copy
  columns on every other post group (large ALTER + UPDATE)
- Found in the 2.4 rollout (dev21: aeav_only, over ten million posts)
2026-09-28 00:03:02 +08:00
wpdev b30baeddf0 Merge pull request 'hardening 2.4: 2meet-data-optimizer 1.4.0 (index-ordered routed sorts, date copy route, count invalidation)' (#8) from hardening/2.4 into main
Tests / PHP Lint (push) Successful in 12s
Tests / Unit Tests (push) Successful in 1m10s
Tests / Integration Tests (push) Failing after 2m0s
Tests / PHPCS (push) Successful in 1m31s
Tests / PHPStan (push) Successful in 50s
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Failing after 14m4s
2026-09-27 23:40:30 +08:00
wpdev 13c4a1bcb9 chore(2meet-data-optimizer): 1.4.0 release notes and version
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 27s
Tests / Unit Tests (pull_request) Successful in 44s
Tests / PHP Lint (pull_request) Successful in 27s
Tests / PHPCS (pull_request) Successful in 35s
Tests / Integration Tests (pull_request) Successful in 1m41s
Tests / PHPStan (pull_request) Successful in 1m23s
- CHANGELOG / readme: PR-118, 122, 123, 127, 128, 129; upgrade step
  `wp tmdo post-status-sync` for the three-column copy index
- Version 1.3.0 -> 1.4.0 (header, TMDO_VERSION, Stable tag)
- PR: PR-124
2026-09-27 23:29:11 +08:00
wpdev 692b23aed9 fix(2meet-data-optimizer): drop cached routed counts when the migration engine rebuilds post rows
- migrate_entity (drift-check --fix), migrate_group and migrate_group_batch
  write flat rows outside the Hook Bus: bump the routed count version
  (once per call / batch; not on dry runs; post rows only)
- dev36: a vendor search kept counting 21515 of 21522 matches after a fix
- Verify: composer test (704), test:integration (598), phpcs, stan
- PR: PR-129
2026-09-27 23:25:03 +08:00
wpdev 8ee3dce0fe perf(2meet-data-optimizer): let the status/date copy route stop at the LIMIT
- Copy route: drop WordPress's GROUP BY when the flat row is the only join,
  and read posts.ID tie-breaks as the flat post_id (own direction, once)
- Post status sync: index (post_status, post_date, post_id) replaces the
  two-column idx_post_status_date (added / dropped by post-status-sync)
- dev36, 235k of 1.3M listings in a price range: main query 1.7 s -> 3 ms
- Verify: composer test (704), test:integration (597), phpcs, stan
- PR: PR-128
2026-09-27 23:12:34 +08:00
wpdev 2aa9058ee9 fix(2meet-data-optimizer): keep a post row's status and date copies when the migration engine rebuilds it
- migrate_entity() (drift-check --fix, per-row backfill) REPLACEs the whole
  flat row and left out the post_status / post_date copies, so a rebuilt row
  had NULL status. One such row turns the status/date copy route off for the
  whole group: on dev36 a single row rebuilt by the stage-3 drift fix made the
  date-ordered price search 1.47 s instead of driving from the flat index
- post rows now carry TMDO_Post_Status_Sync::insert_columns(); the bulk pivot
  backfill (ON DUPLICATE KEY UPDATE) was not affected
- Verify: PostStatusSyncTest (existing and new rows keep their copies),
  integration 593, unit 704, phpcs, stan; dev36 post-status-sync + front-end
- PR: PR-127 (found in 2.4 profiling)
2026-09-27 22:58:09 +08:00
wpdev 66db9e4c56 perf(2meet-data-optimizer): route HivePress's featured-listing lookup
- HivePress runs it on every listing search page from its own
  pre_get_posts: get_posts() with the search's meta query (still unrouted,
  including its sort group), meta_key hp_featured and orderby rand. The
  router declined both shapes, so the lookup stayed native: 2.26–2.38 s of a
  2.5 s page at 1.3M listings on dev36
- a sort group (key NOT EXISTS OR key EXISTS) that no orderby uses matches
  every post and is dropped; meta_key without meta_value and without a
  meta_value sort routes as EXISTS (WP_Query turns it into that clause)
- Verify: PostQueryRouterTest (featured lookup routed, meta_key with a value
  untouched), integration 592, unit 704, phpcs, stan; dev36 front-end
- PR: PR-122 (follow-up)
2026-09-27 22:54:17 +08:00
wpdev 2ed48c3ba3 perf(2meet-data-optimizer): route meta clauses without a value as EXISTS
- WP_Meta_Query adds a value condition only when 'value' is set, so a clause
  like array( 'key' => 'hp_featured' ) only tests that the key exists. The
  router read it as "= ''", declined it, and kept the whole query native
- HivePress runs such a query on every listing search page (featured-listing
  lookup in Attribute::set_search_query(): hp_featured plus the search's own
  filters, ORDER BY RAND()); at 1.3M listings on dev36 it took 2.26 s of a
  2.5 s page, routed or not
- Verify: PostQueryRouterTest (key-only clause routes as IS NOT NULL),
  integration 590, unit 704, phpcs, stan; dev36 front-end
- PR: PR-122
2026-09-27 22:46:52 +08:00
wpdev eac0955b76 perf(2meet-data-optimizer): let flat-first sorts use a (column, id) index
- a group index may end with the entity ID column (post_id, ...)
- flat-first rewrites the posts.ID tie-breaks once, in the sort's direction:
  with a (column, post_id) index MySQL returns rows in order instead of
  sorting the whole match. dev36 (1.3M listings), price range sorted by price:
  850 ms filesort -> 2 ms. Ties are ordered by ID the same way as the column
  (natively ID DESC); pagination stays stable, result sets are unchanged
- Verify: EntityRegistryIndexesTest, PostQueryRouterFoundRowsTest (ASC
  tie-break, single tie-break), unit 704, integration 589, phpcs, stan;
  dev36 with the HivePress indexes (PR-119)
- PR: PR-118
2026-09-27 22:40:59 +08:00
wpdev b2725b219d test(2meet-data-optimizer): live tools no longer flush the object cache
- mut.php called wp_cache_flush() after each write and qbench.php
  wp_cache_flush_group() before each run; with Redis Object Cache's selective
  flush both are one server-side Lua scan over the site's keys, which blocked
  the shared Redis for 5–20 s on dev36 (3 M keys) and broke other sites'
  requests (BUSY / read errors)
- mut.php clears the touched post or user caches plus the runtime cache;
  qbench.php bumps the routed count version (posts' last_changed before 1.3.0)
- tests/ is not packaged; plugin code does not flush
2026-09-27 22:38:16 +08:00
26 changed files with 1166 additions and 129 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer
* Plugin URI: https://2meet.io/2meet-data-optimizer
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
* Version: 1.3.0
* Version: 1.4.3
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// ── Constants ──────────────────────────────────────────────────────────────
define( 'TMDO_VERSION', '1.3.0' );
define( 'TMDO_VERSION', '1.4.3' );
define( 'TMDO_DB_VERSION', '2.1.0' );
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
+123
View File
@@ -7,6 +7,121 @@ Versioning follows [Semantic Versioning](https://semver.org/).
---
## [1.4.2] — 2026-09-28 — 手冊已知問題修正(hardening 2.4.2)
**性質**:修正。無 schema 版本變更;archive 表新增索引(見「升級後」)。來源:`2meet-do-manual.md`
附錄 F(F-2、F-4〜F-8、F-11)。
### Fixed
- **設定頁會說明模式為何沒有變更**:完整表單與 AJAX 區塊儲存都經 `TMDO_Mode_Manager::set()`,
過去忽略它回傳的 `WP_Error`:不安全的跳階或 Promotion Guard 拒絕時,頁面仍顯示「已儲存」、
模式卻沒變。現在完整表單在重新導向後顯示拒絕原因(每位使用者一次),AJAX 儲存以錯誤訊息回傳。(PR-135,F-2)
- **archive 不再重複歸檔**:`wpdo_archive_sweep` 略過已在 archive 表的 meta(依 `original_meta_id`),
過去同一筆 meta 每次 sweep 都再寫一列(dev20:300 列只有 150 筆不同)。archive 表新增
`idx_original_meta` 索引;`wp tmdo install` 會補上索引並刪除重複列(保留最早一列)。(PR-136,F-6)
- **entity 健康面板看得到執行中的 backfill**:批次以 `(entity, group)` 參數排程,面板過去只查
`(entity)`,永遠看不到、一直提示可開始。(PR-137,F-11)
- **停用與解除安裝清掉所有排程**:改用 `wp_unschedule_hook()`,連帶參數排程的事件
(`tmdo_drift_resync`、backfill 批次)也清除;修正錯誤的 `wpdo_user_stress_test_batch` 名稱,
補上 `wpdo_migration_tick`、`wpdo_entity_backfill_batch`、`wpdo_auto_promote_check` 與各壓測批次。
解除安裝另外刪除 `tmdo_*` option 及 `tmdo_*` transient。(PR-138,F-4、F-5)
- 解除安裝被拒絕時的提示改為指向 `wp tmdo bridge-restore-native`(過去說沒有寫回工具);post
壓測工具不再使用已退役的 `wc_product` 表。(PR-139,F-7、F-8)
### 升級後
- 每個站台執行一次 `wp tmdo install`:為 archive 表加上 `idx_original_meta` 並刪除重複的 archive
列(刪除重複只在 CLI 執行,web 啟用外掛時不做)。表很大時請在離峰時段執行。
- **未執行前 archive sweep 會暫停**(沒有索引時 NOT EXISTS 會對每筆候選掃整張 archive 表)。
sweep 只複製、不刪 postmeta,暫停不會遺失資料。
- 解除安裝的 option 清理保留 LatePoint AddOn 的 `tmdo_latepoint_db_version`;設定頁的 AJAX 錯誤訊息
不再 2.5 秒後自動消失。(PR-141,驗收修正)
### 已知、未修改
- 已歸檔的 meta 以 `meta_id` 判斷:文章還原、修改後再次丟到回收桶,archive 保留的是第一次的值。
- post 壓測工具預設仍是 `product`,但 product 已沒有 flat 表,量測會回報 `flat_table_missing`。
---
## [1.4.1] — 2026-09-28 — 2.4 驗收修正
**性質**:修正。無 schema 變更。來源:2.4 全面驗收的 security review 與 correctness review。
### Fixed
- **meta_key 的排序不再被清掉**:查詢帶 `meta_key`,且 orderby 在多個排序(陣列或
`'meta_value date'`)中仍有 `meta_value`、`meta_value_num` 或該 key 名稱時,1.4.0 會把
meta_key 當 EXISTS 路由並清空,WordPress 因此丟掉價格排序。現在只在 orderby 完全沒有引用它時
才路由;`meta_compare_key` 不是 `=`(例如 LIKE 會比對到其他 key)時維持原生;排序形狀的
group 只有在 orderby 沒有引用它、`meta_value(_num)` 或它的 key 時才當成未使用而略過(沒有
meta_key 時它是 WordPress 的主要 clause)。(PR-130)
- **ORDER BY 只改寫單獨的 `posts.ID`**:flat-first 與 copy route 共用 `ties_on_post_id()`;
外掛加上的 `FIELD(wp_posts.ID, …)`、`(wp_posts.ID IN (…))` 等運算式保持原樣。1.4.0 會在
運算式內插入方向,SQL 語法錯誤、頁面沒有結果。正則失敗(PCRE 回溯或 JIT 上限)時回傳原
SQL,不再變成空字串。(PR-131)
- 呼叫端以 `new WP_Query( array( … ) )` 預設的 router 內部 query var(`wpdo_post_clauses`、
`wpdo_post_sort`、`wpdo_post_copy_route`、`wpdo_post_count_sql`)一律清除;copy route 的
排序方向改用白名單。無法從網址觸發。(PR-132)
- 新文章建立的空 flat 列一併寫入 `post_status` / `post_date` 副本(`wp_insert_post` 在
`transition_post_status` 之後才執行,原本留下 NULL,會讓整個 group 關閉 copy route);
`wp tmdo post-status-sync` 完成後遞增路由計數版本(dry run 不遞增)。(PR-133)
### 已知、未修改
- 1.4.0 的 flat-first 排序同值時依 ID 與排序同方向(原生為 ID DESC),集合與總數相同(已接受)。
- 若日後有 group 註冊名為 `post_status` / `post_date` 的欄位,遷移引擎寫副本時 `$data` 與
`$formats` 會錯位;目前沒有這樣的 group。
---
## [1.4.0] — 2026-09-28 — 百萬級 listing 的路由排序與日期查詢(hardening 2.4)
**性質**:效能 + 修正。無 schema 變更;post status/date 副本的索引改為三欄(見「升級後」)。
搭配 HivePress AddOn 0.5.0。來源:dev36 第 3 階壓測(130 萬 listing、121 萬 product、6.2 萬 vendor)。
### Changed
- **flat-first 排序讀 flat 的 post_id 作 tie-break**:`posts.ID` 的 tie-break(ours 與外掛追加的)
改讀相等的 `flat.post_id`,方向與排序一致且只出現一次;group 宣告的索引可以 adapter 的
entity id 欄結尾,`(欄, post_id)` 索引可直接依序讀取、不需 filesort(主查詢 850 ms → 2 ms)。
同值的順序改為依 ID 與排序同方向(原生為 ID DESC);集合、總數不變,翻頁穩定。(PR-118)
- **沒有 value 的 meta 條件以 EXISTS 路由**:clause 沒有 `value` 時(HivePress 的精選 listing
查詢)視同 EXISTS;只帶 `meta_key`、沒有 `meta_value` 也不以 meta_value 排序的查詢亦同;
沒有被 orderby 引用的排序形狀 group 直接移除。HivePress 每個搜尋頁都會跑的精選 listing
查詢(`orderby rand`)改走扁平表:2.26 s → 0.2 s。(PR-122)
- **status/date copy route 在 LIMIT 停下**:只有扁平表一個 JOIN 時去掉 WordPress 的
`GROUP BY posts.ID`,`posts.ID` tie-break 改讀 `flat.post_id`(各自保留方向、重複合併);副本索引改為
`idx_post_status_date_id (post_status, post_date, post_id)`,取代兩欄的 `idx_post_status_date`。
dev36 價格區間(130 萬中 23.5 萬筆符合)主查詢 1.7 s → 3 ms,頁面 2.7 s → 0.34 s。(PR-128)
### Fixed
- 遷移引擎(backfill、`drift-check --fix`)以 REPLACE 重建 post 列時保留 `post_status` /
`post_date` 副本;原本重建後為 NULL,一筆 NULL 就讓整個 group 關閉 copy route。(PR-127)
- 遷移引擎重建 post 列後遞增路由計數版本(每次呼叫/每批一次,dry run 不遞增),
修正後的 found rows 不再沿用舊的快取計數。(PR-129)
### Dev
- live 測試工具(`mut.php`、`qbench.php`)不再清空物件快取(Redis Object Cache 以伺服器端腳本
掃描,會卡住共用 Redis 5–20 s)。(PR-123)
### 升級後
- 只需替換索引:對 `tmdo_post_status_columns` 列出的每個 group 表執行
`ALTER TABLE <表> ADD INDEX idx_post_status_date_id (post_status, post_date, post_id), DROP INDEX idx_post_status_date`
(空表瞬間完成,dev36 130 萬列約 5 s)。未替換前 copy route 照常運作,只是維持 1.3.0 的速度。
- **不要為了換索引執行 `wp tmdo post-status-sync`**:它除了替換索引,還會替**所有**尚未有副本的
post group 加上 `post_status` / `post_date` 欄位並逐列回填(大表 ALTER + 全表 UPDATE;dev36
130 萬列約 75 s,上千萬列的站更久)。只有要讓更多 group 啟用 copy route 時才執行,並先在
離峰時段以 `--dry-run` 確認列數。
- 效能數字(dev36 前台,routed):價格區間 0.34 s、價格排序 0.37 s、時薪排序 0.35 s、
評分排序 0.51 s、關鍵字 + 價格 0.59 s(原生 6–43 s)。
---
## [1.3.0] — 2026-09-27 — 大表索引、路由計數與稽核修正(hardening 2.3)
**性質**:效能 + 稽核修正。無 schema 變更(宣告的複合索引以 ALTER ADD INDEX 就地加上)。
@@ -486,3 +601,11 @@ HivePress AddOn 0.3.0、WooCommerce AddOn 0.2.0。
- PHPCS 套件 + 0/0 達成
- 打包腳本 10 終檢驗收
- WPDO_ → TMDO_ migration CLI
## [1.4.3] — 2026-10-02 — 重新打包
### Changed
- 重新打包(無功能變更):與 WP-MVB v8 樞紐站(dev39)一同驗收後發版;程式碼與前一版相同。
---
+5 -2
View File
@@ -45,7 +45,7 @@
}
/**
* Show a transient status message, then fade it out after 2.5s.
* Show a status message; success fades out after 2.5s, errors stay.
*
* @param {HTMLElement} statusEl
* @param {string} message
@@ -56,6 +56,9 @@
statusEl.style.color = isError ? 'var(--color-error, #b03d3d)' : 'var(--color-success, #5a8c5a)';
statusEl.classList.add( 'visible' );
clearTimeout( statusEl._wpdo_timer );
if ( isError ) {
return;
}
statusEl._wpdo_timer = setTimeout( () => {
statusEl.classList.remove( 'visible' );
}, 2500 );
@@ -92,7 +95,7 @@
if ( data.success ) {
showStatus( statusEl, i18n.saved, false );
} else {
showStatus( statusEl, i18n.error, true );
showStatus( statusEl, ( data.data && data.data.message ) || i18n.error, true );
}
} )
.catch( () => {
+81 -24
View File
@@ -398,17 +398,7 @@ class TMDO_Admin {
// v2.5.4: Entity Bridge settings (v2.11.0: post added).
update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false );
if ( class_exists( 'TMDO_Mode_Manager' ) ) {
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
$new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
: '';
if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) {
TMDO_Mode_Manager::set( $entity_type, $new_mode );
}
}
TMDO_Mode_Manager::reset_cache();
}
self::remember_bridge_mode_refusals( self::apply_bridge_modes( self::posted_bridge_modes() ) );
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
TMDO_Hook_Bus_Bridge::reset_cache();
}
@@ -3144,6 +3134,9 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
if ( isset( $_GET['wpdo_msg'] ) && 'settings_saved' === sanitize_key( wp_unslash( (string) $_GET['wpdo_msg'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( '設定已儲存。', '2meet-data-optimizer' ) . '</p></div>';
}
foreach ( self::pull_bridge_mode_refusals() as $refused ) {
echo '<div class="notice notice-error"><p>' . esc_html__( '模式未變更:', '2meet-data-optimizer' ) . esc_html( $refused ) . '</p></div>';
}
$enabled = class_exists( 'TMDO_Email_Notifier' ) && TMDO_Email_Notifier::is_enabled();
$email = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::recipient() : '';
$throttle = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::throttle_hours() : 24;
@@ -3743,22 +3736,33 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
}
}
private static function save_section_entity_bridge(): void {
/**
* Save the Entity Bridge section (Hook Bus switch and bridge modes).
*
* @return string[] Refused changes, "<entity>:<reason>".
*/
private static function save_section_entity_bridge(): array {
update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false );
if ( class_exists( 'TMDO_Mode_Manager' ) ) {
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
$new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
: '';
if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) {
TMDO_Mode_Manager::set( $entity_type, $new_mode );
}
}
TMDO_Mode_Manager::reset_cache();
}
$refused = self::apply_bridge_modes( self::posted_bridge_modes() );
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
TMDO_Hook_Bus_Bridge::reset_cache();
}
return $refused;
}
/**
* Bridge modes posted by the settings form, keyed by entity type ('' when absent).
*
* @return array<string, string>
*/
private static function posted_bridge_modes(): array {
$posted = array();
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
$posted[ $entity_type ] = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
: '';
}
return $posted;
}
private static function save_section_hp_transient(): void {
@@ -3786,6 +3790,56 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
}
// phpcs:enable WordPress.Security.NonceVerification.Missing,Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.FunctionComment.WrongStyle
/**
* Apply posted bridge modes through TMDO_Mode_Manager::set(). Empty or
* unknown modes are skipped; refused changes (unsafe jump, Promotion Guard)
* are returned instead of being dropped silently (2.4.2 PR-135).
*
* @param array<string, string> $posted Mode per entity type.
* @return string[] Refused changes, "<entity>:<reason>".
*/
public static function apply_bridge_modes( array $posted ): array {
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
return array();
}
$refused = array();
foreach ( $posted as $entity_type => $new_mode ) {
if ( ! TMDO_Mode_Manager::is_valid_mode( (string) $new_mode ) ) {
continue;
}
$result = TMDO_Mode_Manager::set( (string) $entity_type, (string) $new_mode );
if ( is_wp_error( $result ) ) {
$refused[] = $entity_type . ':' . $result->get_error_message();
}
}
TMDO_Mode_Manager::reset_cache();
return $refused;
}
/**
* Keep refused bridge-mode changes for the page the settings form redirects to.
*
* @param string[] $refused Refused changes.
* @return void
*/
public static function remember_bridge_mode_refusals( array $refused ): void {
if ( $refused ) {
set_transient( 'wpdo_mode_refused_' . get_current_user_id(), $refused, 5 * MINUTE_IN_SECONDS );
}
}
/**
* Refused bridge-mode changes remembered for this user, once.
*
* @return string[]
*/
public static function pull_bridge_mode_refusals(): array {
$key = 'wpdo_mode_refused_' . get_current_user_id();
$refused = get_transient( $key );
delete_transient( $key );
return is_array( $refused ) ? $refused : array();
}
/**
* AJAX: save a single settings section without a full page reload.
*/
@@ -3807,7 +3861,10 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
if ( ! isset( $handlers[ $section ] ) ) {
wp_send_json_error( 'invalid_section', 400 );
}
self::{$handlers[ $section ]}();
$refused = self::{$handlers[ $section ]}();
if ( is_array( $refused ) && $refused ) {
wp_send_json_error( array( 'message' => __( '部分模式未變更:', '2meet-data-optimizer' ) . implode( ';', $refused ) ) );
}
wp_send_json_success( array( 'message' => __( '設定已儲存。', '2meet-data-optimizer' ) ) );
}
}
+55 -45
View File
@@ -217,6 +217,16 @@ class TMDO_Installer {
// Idempotent — only creates tables that don't already exist.
self::install_v2_tables();
// 1.4.2: archive sweeps before this release copied trashed meta daily.
// One unbounded DELETE: CLI only, never in a web activation.
if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( 'TMDO_Zone_Archive' ) ) {
global $wpdb;
$archive = TMDO_Zone_Archive::table();
if ( $archive === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $archive ) ) ) ) {
TMDO_Zone_Archive::remove_duplicates();
}
}
update_option( 'wpdo_db_version', self::SCHEMA_VERSION );
// Register wpdo_features with autoload=no so it doesn't inflate every page load.
@@ -771,30 +781,45 @@ class TMDO_Installer {
return $status;
}
/**
* Every cron hook the plugin schedules. Cleared with wp_unschedule_hook(),
* which also removes events scheduled with args (tmdo_drift_resync( 'post' ),
* backfill batches) that wp_clear_scheduled_hook( $hook ) leaves behind
* (2.4.2 PR-138).
*/
private const CRON_HOOKS = array(
'wpdo_warm_cleanup',
'wpdo_archive_sweep',
'wpdo_errors_gc',
'wpdo_flush_views',
'wpdo_remove_uae_plugin_dir',
'wpdo_daily_health_check',
'wpdo_snapshot_prune_daily',
'wpdo_fsm_automator_run',
'wpdo_collect_site_metrics',
'wpdo_health_snapshot_monthly',
'wpdo_post_shadow_verify',
'wpdo_term_comment_shadow_verify',
'tmdo_drift_resync',
'tmdo_ensure_group_indexes',
'wpdo_entity_backfill_batch',
'wpdo_migration_tick',
'wpdo_auto_promote_check',
'wpdo_post_stress_test_batch',
'wpdo_stress_test_batch',
'wpdo_term_stress_test_batch',
'wpdo_comment_stress_test_batch',
);
/**
* Called on plugin deactivation. Non-destructive.
*/
public static function deactivate(): void {
wp_clear_scheduled_hook( 'wpdo_warm_cleanup' );
wp_clear_scheduled_hook( 'wpdo_archive_sweep' );
// v2.14.0: clear all plugin cron hooks (was incomplete pre-v2.14.0).
wp_clear_scheduled_hook( 'wpdo_errors_gc' );
wp_clear_scheduled_hook( 'wpdo_flush_views' );
wp_clear_scheduled_hook( 'wpdo_remove_uae_plugin_dir' );
wp_clear_scheduled_hook( 'wpdo_daily_health_check' );
wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' );
wp_clear_scheduled_hook( 'wpdo_fsm_automator_run' );
wp_clear_scheduled_hook( 'wpdo_collect_site_metrics' );
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
wp_clear_scheduled_hook( 'tmdo_drift_resync' );
wp_clear_scheduled_hook( 'tmdo_ensure_group_indexes' );
wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_comment_stress_test_batch' );
foreach ( self::CRON_HOOKS as $hook ) {
wp_unschedule_hook( $hook );
}
}
// ── v2.14.0: Multisite-aware site cleanup ─────────────────────────────────
@@ -967,42 +992,26 @@ class TMDO_Installer {
// Sweep any remaining wpdo_* options (stragglers introduced post-v2.14.0
// or by 3rd-party hooks). Validates name pattern before delete to avoid
// accidental option removal.
// 1.4.2: tmdo_* options and their transients too (tmdo_schema_deferred,
// tmdo_entity_indexes, tmdo_cov_* …) — a reinstall saw stale ones.
// tmdo_latepoint_* belongs to the LatePoint add-on.
foreach ( array( 'wpdo_', 'tmdo_', '_transient_tmdo_', '_transient_timeout_tmdo_' ) as $prefix ) {
$residual = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like( 'wpdo_' ) . '%'
$wpdb->esc_like( $prefix ) . '%'
)
);
foreach ( (array) $residual as $opt ) {
if ( preg_match( '/^wpdo_[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
if ( preg_match( '/^(_transient_(timeout_)?)?(wpdo|tmdo)_(?!latepoint_)[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
++$options_deleted;
}
}
}
// ── 5) Clear cron events ──────────────────────────────────────────────
$crons = array(
'wpdo_warm_cleanup',
'wpdo_archive_sweep',
'wpdo_errors_gc',
'wpdo_flush_views',
'wpdo_remove_uae_plugin_dir',
'wpdo_daily_health_check',
'wpdo_snapshot_prune_daily',
'wpdo_fsm_automator_run',
'wpdo_collect_site_metrics',
'wpdo_health_snapshot_monthly',
'wpdo_post_shadow_verify',
'wpdo_term_comment_shadow_verify',
'tmdo_drift_resync',
'tmdo_ensure_group_indexes',
'wpdo_post_stress_test_batch',
'wpdo_user_stress_test_batch',
'wpdo_term_stress_test_batch',
'wpdo_comment_stress_test_batch',
);
foreach ( $crons as $hook ) {
if ( false !== wp_next_scheduled( $hook ) ) {
wp_clear_scheduled_hook( $hook );
foreach ( self::CRON_HOOKS as $hook ) {
if ( wp_unschedule_hook( $hook ) ) {
++$crons_cleared;
}
}
@@ -1440,7 +1449,8 @@ class TMDO_Installer {
original_meta_id bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_post_id (post_id),
KEY idx_post_type_date (post_type, archived_at)
KEY idx_post_type_date (post_type, archived_at),
KEY idx_original_meta (original_meta_id)
) {$charset};";
foreach ( $sqls as $sql ) {
@@ -872,7 +872,6 @@ final class TMDO_Post_Stress_Tester {
global $wpdb;
$prefix = $wpdb->prefix . 'wpdo_post_';
$map = array(
'product' => $prefix . 'wc_product',
'hp_listing' => $prefix . 'hp_listing_core',
'hp_request' => $prefix . 'hp_request_core',
'hp_vendor' => $prefix . 'hp_vendor_core',
@@ -1036,7 +1035,6 @@ final class TMDO_Post_Stress_Tester {
return array(
$prefix . 'wp_core',
$prefix . 'attachment',
$prefix . 'wc_product',
$prefix . 'hp_listing_core',
$prefix . 'hp_request_core',
$prefix . 'hp_vendor_core',
+14 -1
View File
@@ -325,7 +325,20 @@ final class TMDO_Entity_Health {
return TMDO_Mode_Manager::ALL_MODES[ $idx - 1 ];
}
/**
* Whether a backfill batch is scheduled for any group of the entity. Batches
* are scheduled with (entity, group) args, so looking for (entity) alone
* never found one (2.4.2 PR-137).
*
* @param string $entity_type Entity type.
* @return bool
*/
private static function is_backfill_active( string $entity_type ): bool {
return (bool) wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type ) );
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
if ( wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type, $group ) ) ) {
return true;
}
}
return false;
}
}
@@ -176,6 +176,9 @@ final class TMDO_Entity_Migration_Engine {
// 標記完成
if ( ! $options['dry_run'] ) {
self::mark_migration_completed( $entity_type, $group_name );
if ( $stats['migrated'] > 0 ) {
self::counts_changed( $entity_type );
}
}
$stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 );
@@ -183,6 +186,19 @@ final class TMDO_Entity_Migration_Engine {
return $stats;
}
/**
* Rows rebuilt outside the Hook Bus (backfill, drift-check --fix): routed
* post counts cached for the old rows are stale (2.4 PR-129).
*
* @param string $entity_type Entity type.
* @return void
*/
private static function counts_changed( string $entity_type ): void {
if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Query_Router' ) ) {
TMDO_Post_Query_Router::bump_count_version();
}
}
/**
* Rebuild one entity's row of a group from native meta.
*
@@ -194,7 +210,7 @@ final class TMDO_Entity_Migration_Engine {
if ( ! $adapter || empty( $group_fields ) ) {
return false;
}
return self::migrate_single_entity(
$migrated = self::migrate_single_entity(
$entity_type,
$group_name,
$entity_id,
@@ -203,6 +219,10 @@ final class TMDO_Entity_Migration_Engine {
$adapter,
false
);
if ( $migrated ) {
self::counts_changed( $entity_type );
}
return $migrated;
}
/**
@@ -278,6 +298,15 @@ final class TMDO_Entity_Migration_Engine {
}
}
// REPLACE drops columns it is not given: keep a post row's status/date
// copies, or one NULL row turns the copy route off for the group (2.4 PR-127).
if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Status_Sync' ) ) {
foreach ( TMDO_Post_Status_Sync::insert_columns( $entity_id, $group_name ) as $col => $value ) {
$data[ $col ] = $value;
$formats[] = '%s';
}
}
if ( $dry_run ) {
return true;
}
@@ -400,6 +429,9 @@ final class TMDO_Entity_Migration_Engine {
$total = $prev_total + $batch_migrated;
$done = count( $entity_ids ) < $batch_size;
if ( $batch_migrated > 0 ) {
self::counts_changed( $entity_type );
}
if ( $done ) {
self::mark_migration_completed( $entity_type, $group_name );
@@ -159,9 +159,12 @@ final class TMDO_Entity_Registry {
self::$scopes[ $entity_type ][ $group_name ] = $post_types;
}
$keys = array_column( $normalized, 'key' );
// A sort index may end with the entity ID (flat-first tie-break, 2.4 PR-118).
$id_col = (string) self::$adapters[ $entity_type ]->get_entity_id_column();
foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) {
$columns = array_values( array_map( 'strval', (array) $columns ) );
if ( $columns && ! array_diff( $columns, $keys ) ) {
$fields = '' !== $id_col && end( $columns ) === $id_col ? array_slice( $columns, 0, -1 ) : $columns;
if ( $fields && ! array_diff( $fields, $keys ) ) {
self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns;
}
}
@@ -29,7 +29,10 @@ final class TMDO_Post_Status_Sync {
public const OPTION = 'tmdo_post_status_columns';
private const INDEX = 'idx_post_status_date';
private const INDEX = 'idx_post_status_date_id';
/** The 2.2–2.3 (post_status, post_date) index, replaced by INDEX. */
private const LEGACY_INDEX = 'idx_post_status_date';
/**
* Hook the sync into WordPress.
@@ -160,6 +163,10 @@ final class TMDO_Post_Status_Sync {
}
self::set_state( $group, 'ready' );
}
// Routed counts cached while the copies were stale must go.
if ( ! $dry_run && class_exists( 'TMDO_Post_Query_Router' ) ) {
TMDO_Post_Query_Router::bump_count_version();
}
return $report;
}
@@ -203,7 +210,12 @@ final class TMDO_Post_Status_Sync {
$changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL';
}
if ( ! in_array( self::INDEX, $indexes, true ) ) {
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`)';
// Ends with post_id: the router reads the date route's ID tie-break
// from it, so MySQL walks the index and stops at the LIMIT (2.4 PR-128).
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`, `post_id`)';
}
if ( in_array( self::LEGACY_INDEX, $indexes, true ) ) {
$changes[] = 'DROP INDEX `' . self::LEGACY_INDEX . '`';
}
if ( $changes && false === $wpdb->query( "ALTER TABLE `{$table}` " . implode( ', ', $changes ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fixed DDL on a registry table name.
return false;
+153 -23
View File
@@ -26,8 +26,9 @@
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
* a single-post-type search with explicit statuses and date ordering
* INNER JOINs the table, filters the copies and orders by the copied
* post_date, so MySQL can walk `idx_post_status_date` and stop at the
* LIMIT. Not with NOT EXISTS: posts without a flat row must still match.
* post_date (ID tie-breaks read as the flat post_id), so MySQL can walk
* `idx_post_status_date_id` and stop at the LIMIT. Not with NOT EXISTS:
* posts without a flat row must still match.
*
* Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every
* match inside the page query); found_posts_query answers from a COUNT of the
@@ -124,9 +125,17 @@ class TMDO_Post_Query_Router {
global $wpdb;
foreach ( TMDO_Post_Fields::groups_for_post_type( $post->post_type ) as $group ) {
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
$wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`) VALUES (%d)", (int) $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
// With the status / date copies: this runs after transition_post_status,
// and one NULL copy turns the copy route off for the whole group.
$copies = class_exists( 'TMDO_Post_Status_Sync' ) ? TMDO_Post_Status_Sync::insert_columns( (int) $post_id, $group ) : array();
if ( $copies ) {
$wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`, `post_status`, `post_date`) VALUES (%d, %s, %s)", (int) $post_id, $copies['post_status'], $copies['post_date'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
continue;
}
$wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`) VALUES (%d)", (int) $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
@@ -137,6 +146,11 @@ class TMDO_Post_Query_Router {
* @return void
*/
public function pre_get_posts( \WP_Query $query ): void {
// The router's query vars are its own: drop any a caller passed in.
foreach ( array( self::QUERY_VAR, self::SORT_VAR, self::COPY_VAR, self::COUNT_VAR ) as $var ) {
$query->set( $var, '' );
}
// Mode gate: only run when post mode is reads_from_flat.
if ( ! self::is_router_active() ) {
return;
@@ -171,8 +185,11 @@ class TMDO_Post_Query_Router {
// meta_key + orderby meta_value(_num): native INNER JOINs the key
// (EXISTS) and orders by meta_value+0 (NUMERIC) or meta_value.
// A key comparison other than equality (meta_compare_key LIKE …) matches
// other keys too: not a test on one flat column.
$meta_key = $query->get( 'meta_key' );
$take_key = is_string( $meta_key ) && in_array( $sort_name, array( 'meta_value', 'meta_value_num' ), true )
$key_exact = in_array( (string) $query->get( 'meta_compare_key' ), array( '', '=' ), true );
$take_key = $key_exact && is_string( $meta_key ) && in_array( $sort_name, array( 'meta_value', 'meta_value_num' ), true )
&& null !== self::field_for( array( 'key' => $meta_key ), $pt );
if ( $take_key ) {
$field = self::field_for( array( 'key' => $meta_key ), $pt );
@@ -191,6 +208,27 @@ class TMDO_Post_Query_Router {
$routed[ $pt ][] = $route;
$sort = self::sort_for( $pt, $route, $direction );
}
// meta_key alone (no meta_value, not ordered by): WP_Query adds a clause
// that tests the key exists — HivePress's featured-listing lookup. Any
// orderby naming meta_value, meta_value_num or the key sorts by that
// clause, which clearing meta_key would drop.
$exists_key = ! $take_key && $key_exact && is_string( $meta_key ) && '' !== $meta_key
&& '' === (string) $query->get( 'meta_value' ) && '' === (string) $query->get( 'meta_compare' )
&& ! array_intersect( $named, array( 'meta_value', 'meta_value_num', $meta_key ) )
&& null !== self::field_for( array( 'key' => $meta_key ), $pt );
if ( $exists_key ) {
$route = self::route_clause(
array(
'key' => $meta_key,
'compare' => 'EXISTS',
),
self::field_for( array( 'key' => $meta_key ), $pt )
);
if ( ! $route ) {
return;
}
$routed[ $pt ][] = $route;
}
foreach ( $raw_meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) ) {
@@ -200,6 +238,13 @@ class TMDO_Post_Query_Router {
if ( ! isset( $clause['key'] ) ) {
// Sort shape: matches every post, only orders by the key.
$name = 'AND' === $relation ? self::sort_shape( $clause ) : '';
// Matches every post and orders nothing here (a copy of a search's meta
// query) — unless orderby names it, or meta_value(_num) / its key, which
// sort by it when it is WordPress's primary (first) clause.
if ( '' !== $name && ! array_intersect( $named, array( $name, 'meta_value', 'meta_value_num', (string) ( $clause[ $name ]['key'] ?? '' ) ) )
&& self::field_for( $clause[ $name ], $pt ) ) {
continue;
}
$field = '' !== $name && $name === $sort_name ? self::field_for( $clause[ $name ], $pt ) : null;
if ( $field ) {
$route = self::route_clause( array( 'compare' => 'EXISTS' ) + $clause[ $name ], $field );
@@ -239,7 +284,7 @@ class TMDO_Post_Query_Router {
if ( empty( $routed ) && empty( $sort ) ) {
return;
}
if ( $take_key ) {
if ( $take_key || $exists_key ) {
$query->set( 'meta_key', '' );
}
if ( $sort ) {
@@ -357,7 +402,7 @@ class TMDO_Post_Query_Router {
if ( empty( $copy ) ) {
return $orderby;
}
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . ( 'ASC' === $copy['order'] ? 'ASC' : 'DESC' );
}
/**
@@ -678,6 +723,12 @@ class TMDO_Post_Query_Router {
*/
private static function route_clause( array $clause, array $field ): ?array {
$compare = strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) );
// Without 'value', WP_Meta_Query checks only that the key exists (it adds a
// value condition only when one is set): HivePress's featured-listing
// lookup, run on every search page, is array( 'key' => 'hp_featured' ).
if ( ! array_key_exists( 'value', $clause ) && ! in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
$compare = 'EXISTS';
}
$type = strtoupper( trim( (string) ( $clause['type'] ?? '' ) ) );
$type = '' === $type ? 'CHAR' : $type;
$value = $clause['value'] ?? '';
@@ -721,7 +772,92 @@ class TMDO_Post_Query_Router {
* @return string
*/
private static function routed_request( string $request, \WP_Query $query ): string {
return self::flat_first( self::without_found_rows( $request ), $query );
return self::copy_ordered( self::flat_first( self::without_found_rows( $request ), $query ), $query );
}
/**
* The copy route's request without WordPress's GROUP BY (when the flat row
* is the only join) and with posts.ID tie-breaks read as the flat post_id,
* each in its own direction, once. MariaDB then walks the
* (post_status, post_date, post_id) index and stops at the LIMIT instead
* of sorting every match (dev36, 235k of 1.3M listings: 1.7 s → 3 ms).
*
* @param string $request SELECT statement.
* @param \WP_Query $query Current WP_Query.
* @return string
*/
private static function copy_ordered( string $request, \WP_Query $query ): string {
$copy = $query->get( self::COPY_VAR );
if ( empty( $copy ) ) {
return $request;
}
return self::ties_on_post_id( self::without_group_by( $request ), self::table_alias( $copy['post_type'], $copy['group'] ), '' );
}
/**
* ORDER BY terms that are posts.ID alone (with or without a direction)
* read as the equal flat post_id — in $direction, or each in its own when
* $direction is '' — and only once. posts.ID inside an expression a plugin
* added (FIELD(), IN …) is left alone: a direction inserted there breaks
* the SQL.
*
* @param string $request SELECT statement.
* @param string $alias Flat table alias.
* @param string $direction 'ASC', 'DESC' or '' (keep each term's own).
* @return string
*/
private static function ties_on_post_id( string $request, string $alias, string $direction ): string {
global $wpdb;
$tie = "`{$alias}`.`post_id` ";
$id = '`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?';
return self::replaced(
preg_replace_callback(
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
static function ( $m ) use ( $id, $tie, $direction ) {
$order = self::replaced(
preg_replace_callback(
'/(?<=ORDER BY |,)(\s*)' . $id . '(?:\s+(ASC|DESC)\b)?(?=\s*(?:,|\bLIMIT\b|$))/i',
static fn( $t ) => $t[1] . $tie . ( '' !== $direction ? $direction : ( 'DESC' === strtoupper( $t[2] ?? '' ) ? 'DESC' : 'ASC' ) ),
$m[0]
),
$m[0]
);
return self::replaced( preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . '(ASC|DESC))(\s*,\s*' . preg_quote( $tie, '/' ) . '\2)+/', '$1', $order ), $order );
},
$request
),
$request
);
}
/**
* A preg_* result, or the text it was given when PCRE failed (backtrack
* or JIT stack limit on a very long request): the query stays unrouted
* in shape rather than empty.
*
* @param string|null $result preg_* return value.
* @param string $fallback Input text.
* @return string
*/
private static function replaced( ?string $result, string $fallback ): string {
return $result ?? $fallback;
}
/**
* WordPress groups by posts.ID for any tax or meta query. With a flat row
* (one per post) as the only join there is nothing to collapse, and the
* GROUP BY makes MySQL sort the whole match in a temporary table
* (WooCommerce's product_visibility NOT IN on searches is a subquery).
*
* @param string $request SELECT statement.
* @return string
*/
private static function without_group_by( string $request ): string {
global $wpdb;
if ( 1 !== preg_match_all( '/\bJOIN\b/i', $request ) ) {
return $request;
}
return self::replaced( preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', $request, 1 ), $request );
}
/**
@@ -790,20 +926,14 @@ class TMDO_Post_Query_Router {
if ( 1 !== $replaced ) {
return $request;
}
// WordPress groups by posts.ID for any tax or meta query. With the flat row
// (one per post) as the only join there is nothing to collapse, and the
// GROUP BY makes MySQL sort the whole match in a temporary table
// (WooCommerce's product_visibility NOT IN on searches is a subquery).
if ( 1 === preg_match_all( '/\bJOIN\b/i', (string) $flat_first ) ) {
$flat_first = preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', (string) $flat_first, 1 );
}
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id.
$flat_first = preg_replace_callback(
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
static fn( $m ) => preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)/', "`{$alias}`.`post_id`", $m[0] ),
(string) $flat_first
);
return (string) preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', (string) $flat_first, 1 );
$flat_first = self::without_group_by( (string) $flat_first );
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat
// post_id, once, in the sort's direction: a (column, post_id) index then
// returns rows in order with no filesort. Ties are ordered by ID the same
// way as the column (natively ID DESC); pagination stays stable.
$direction = 'ASC' === strtoupper( (string) $sort['order'] ) ? 'ASC' : 'DESC';
$flat_first = self::ties_on_post_id( $flat_first, $alias, $direction );
return self::replaced( preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', $flat_first, 1 ), $request );
}
/**
@@ -813,7 +943,7 @@ class TMDO_Post_Query_Router {
* @return string
*/
private static function without_found_rows( string $request ): string {
return (string) preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
return self::replaced( preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 ), $request );
}
/**
@@ -210,7 +210,15 @@ class TMDO_Zone_Archive {
global $wpdb;
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
// Rows already archived are skipped: the sweep used to copy the same
// trashed meta again every day (2.4.2 PR-136).
$table = self::table();
// Without the index each candidate scans the archive: wait for `wp tmdo install`.
if ( ! TMDO_IS_SQLITE && ! $wpdb->get_var( "SHOW INDEX FROM `{$table}` WHERE Key_name = 'idx_original_meta'" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- internal table name
return 0;
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
@@ -218,12 +226,14 @@ class TMDO_Zone_Archive {
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_status = 'trash'
AND p.post_modified_gmt < %s
AND NOT EXISTS ( SELECT 1 FROM `{$table}` a WHERE a.original_meta_id = pm.meta_id )
LIMIT %d",
$cutoff,
$limit
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! $rows ) {
return 0;
@@ -245,6 +255,21 @@ class TMDO_Zone_Archive {
return count( $entries );
}
/**
* Delete archive rows that copy the same original meta row again, keeping
* the first copy (sweeps before 1.4.2 archived trashed meta every day).
* Rows without an original meta id are left alone.
*
* @return int Rows deleted.
*/
public static function remove_duplicates(): int {
global $wpdb;
$table = self::table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
$deleted = $wpdb->query( "DELETE FROM `{$table}` WHERE original_meta_id > 0 AND id NOT IN ( SELECT keep_id FROM ( SELECT MIN(id) AS keep_id FROM `{$table}` WHERE original_meta_id > 0 GROUP BY original_meta_id ) AS k )" );
return is_int( $deleted ) ? $deleted : 0;
}
/**
* Get archive statistics.
*
+24 -1
View File
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 8.1
Stable tag: 1.3.0
Stable tag: 1.4.3
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -107,6 +107,29 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
== Changelog ==
= 1.4.3 =
* 重新打包(無功能變更):與 WP-MVB v8 樞紐站(dev39)一同驗收後發版;程式碼與前一版相同。
= 1.4.2 =
* The settings page shows why a bridge-mode change was refused (unsafe jump, Promotion Guard) instead of reporting it saved
* Archive sweep skips meta already archived; `wp tmdo install` adds an index on original_meta_id and removes duplicate archive rows (the sweep waits until the index exists)
* Entity health sees a running backfill (batches are scheduled with entity and group args)
* Deactivate and uninstall clear every scheduled event, including ones scheduled with args; uninstall also removes tmdo_* options and transients
* The refused-uninstall notice points to `wp tmdo bridge-restore-native`; the post stress tester no longer uses the retired wc_product table
= 1.4.1 =
* A query ordered by its meta_key among several orderings (or by the key's name) keeps meta_key native; meta_compare_key other than '=' is not routed; a sort group stays when orderby may use it as the primary clause
* Only standalone posts.ID terms in ORDER BY become flat post_id tie-breaks (FIELD()/IN expressions from other plugins are left alone); PCRE failures keep the original SQL
* Router query vars preset by callers are dropped
* New posts' flat rows get their status/date copies; post-status-sync invalidates cached routed counts
= 1.4.0 =
* Flat-first sorts read the ID tie-break from the flat post_id, so a declared (column, post_id) index returns rows in order; ties now follow the sort direction
* Meta clauses without a value route as EXISTS; HivePress's featured-listing lookup on every search page uses the flat table
* Date-ordered routed searches drop the redundant GROUP BY and use a (post_status, post_date, post_id) index (replace the index on the tables listed in `tmdo_post_status_columns`; `wp tmdo post-status-sync` also adds the copy columns to every other post group)
* Rows rebuilt by the migration engine keep their status/date copies and invalidate cached routed counts
* Live test tools no longer flush the object cache
= 1.3.0 =
* Declared flat-table indexes are added only from WP-CLI or WP-Cron, one process at a time (MySQL GET_LOCK); web requests schedule `tmdo_ensure_group_indexes`, and doctor lists indexes not added yet
* Routed searches with post__not_in count from a cached base count minus the excluded posts; the cache key ignores wpdb's per-request placeholder escape and follows post saves and managed-key writes only, not every post meta write (HivePress keyword + price search no longer slower than native)
+2 -1
View File
@@ -180,7 +180,7 @@ if ( ! function_exists( 'do_action_ref_array' ) ) {
}
}
if ( ! function_exists( 'wp_next_scheduled' ) ) {
function wp_next_scheduled( string $hook ): int|false { return false; }
function wp_next_scheduled( string $hook, array $args = array() ): int|false { return $GLOBALS['_wp_next_scheduled'][ $hook ][ md5( serialize( $args ) ) ] ?? false; }
}
if ( ! function_exists( 'wp_schedule_event' ) ) {
function wp_schedule_event( int $t, string $r, string $h ): bool { return true; }
@@ -188,6 +188,7 @@ if ( ! function_exists( 'wp_schedule_event' ) ) {
if ( ! function_exists( 'wp_schedule_single_event' ) ) {
function wp_schedule_single_event( int $ts, string $hook ): bool { return true; }
}
if ( ! function_exists( 'wp_unschedule_hook' ) ) { function wp_unschedule_hook( string $hook ): int|false { $GLOBALS['_wp_unscheduled'][] = $hook; return 0; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) {
function wp_clear_scheduled_hook( string $hook ): int|false { return 0; }
}
+2 -1
View File
@@ -303,7 +303,8 @@ class DriftCheckTest extends TestCase {
}
// Literal: uninstall.php does not load TMDO_Drift_Check.
$installer = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php' );
$this->assertSame( 2, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'cleared on deactivate and uninstall' );
// One CRON_HOOKS list serves deactivate and uninstall (InstallerCleanupTest covers both).
$this->assertSame( 1, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'listed in CRON_HOOKS' );
}
public function test_the_daily_tick_runs_on_the_daily_health_check_hook(): void {
@@ -68,6 +68,50 @@ class InstallerCleanupTest extends TestCase {
$GLOBALS['_wp_options'] = array();
}
/**
* Events scheduled with args (tmdo_drift_resync( 'post' ), backfill
* batches) are not cleared by wp_clear_scheduled_hook( $hook ); the user
* stress hook was misnamed and three hooks were missing (2.4.2 PR-138, F-4).
*/
private const ARG_AND_MISSED_HOOKS = array( 'tmdo_drift_resync', 'wpdo_entity_backfill_batch', 'wpdo_stress_test_batch', 'wpdo_migration_tick', 'wpdo_auto_promote_check' );
public function test_deactivate_unschedules_every_hook_whatever_its_args(): void {
$GLOBALS['_wp_unscheduled'] = array();
WPDO_Installer::deactivate();
foreach ( self::ARG_AND_MISSED_HOOKS as $hook ) {
$this->assertContains( $hook, $GLOBALS['_wp_unscheduled'], $hook );
}
}
public function test_uninstall_unschedules_every_hook_whatever_its_args(): void {
$GLOBALS['_wp_unscheduled'] = array();
WPDO_Installer::drop_all_tables_for_current_blog();
foreach ( self::ARG_AND_MISSED_HOOKS as $hook ) {
$this->assertContains( $hook, $GLOBALS['_wp_unscheduled'], $hook );
}
}
/** tmdo_* options and transients were left behind; a reinstall then saw stale deferrals and indexes (F-5). */
public function test_uninstall_removes_tmdo_options_and_transients(): void {
global $wpdb;
foreach ( array( 'tmdo_schema_deferred', 'tmdo_entity_indexes', '_transient_tmdo_cov_x', '_transient_timeout_tmdo_cov_x', 'hivepress_settings', 'tmdo_latepoint_db_version' ) as $name ) {
$wpdb->insert( $wpdb->options, array( 'option_name' => $name, 'option_value' => '1' ) );
$GLOBALS['_wp_options'][ $name ] = '1';
}
WPDO_Installer::drop_all_tables_for_current_blog();
foreach ( array( 'tmdo_schema_deferred', 'tmdo_entity_indexes', '_transient_tmdo_cov_x', '_transient_timeout_tmdo_cov_x' ) as $name ) {
$this->assertArrayNotHasKey( $name, $GLOBALS['_wp_options'], $name );
}
$this->assertArrayHasKey( 'hivepress_settings', $GLOBALS['_wp_options'], 'other plugins untouched' );
$this->assertArrayHasKey( 'tmdo_latepoint_db_version', $GLOBALS['_wp_options'], 'the LatePoint add-on owns it' );
}
public function test_drops_static_tables(): void {
global $wpdb;
+99 -1
View File
@@ -31,7 +31,7 @@ class PostQueryRouterCopyTest extends TestCase {
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date (post_status, post_date) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date_id (post_status, post_date, post_id) ) ENGINE=InnoDB" );
$wpdb->query( 'INSERT INTO `' . self::TABLE . "` (post_id, rc_price, post_status, post_date) VALUES (1, 10, 'publish', '2026-01-01 00:00:00')" );
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'ready' ) );
@@ -58,6 +58,104 @@ class PostQueryRouterCopyTest extends TestCase {
$mode->setValue( null, null );
}
/**
* The full request WordPress would build for the routed search, after posts_request.
*
* @param string $extra_join Another JOIN (a tax query's).
* @param string $ties ID tie-breaks plugins appended.
*/
private function request( string $extra_join = '', string $ties = ', {posts}.ID DESC, {posts}.ID DESC' ): string {
global $wpdb;
$ties = str_replace( '{posts}', $wpdb->posts, $ties );
$query = new WP_Query();
foreach ( array(
'post_type' => 'rc_item',
'post_status' => 'publish',
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
$request = 'SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts ' . $router->posts_join( $extra_join, $query )
. ' WHERE 1=1 ' . $router->posts_where( " AND wp_posts.post_status = 'publish'", $query )
. " GROUP BY {$wpdb->posts}.ID ORDER BY " . $router->posts_orderby( 'wp_posts.post_date DESC', $query ) . $ties . ' LIMIT 0, 10';
return $router->posts_request( $request, $query );
}
/**
* 2.4 PR-128: with GROUP BY or a posts.ID tie-break MariaDB sorts every
* match (dev36, 235k of 1.3M listings: 1.7 s); without them it walks
* (post_status, post_date, post_id) backwards and stops at the LIMIT (3 ms).
*/
public function test_copy_route_orders_by_the_flat_date_and_post_id_without_group_by(): void {
$sql = $this->request();
$this->assertStringNotContainsString( 'GROUP BY', $sql, 'one flat row per post: nothing to collapse' );
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql );
}
public function test_copy_route_keeps_each_tie_break_direction(): void {
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` ASC LIMIT 0, 10', $this->request( '', ', {posts}.ID ASC' ) );
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC LIMIT 0, 10', $this->request( '', '' ) );
}
public function test_copy_route_leaves_posts_id_inside_expressions_alone(): void {
global $wpdb;
$sql = $this->request( '', ', FIELD({posts}.ID,5,9) DESC, ({posts}.ID IN (3,4)) DESC, {posts}.ID DESC' );
$this->assertStringEndsWith( "`post_date` DESC, FIELD({$wpdb->posts}.ID,5,9) DESC, ({$wpdb->posts}.ID IN (3,4)) DESC, " . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql );
}
/**
* The router's query vars are its own: values a caller passed in
* (new WP_Query( array( 'wpdo_post_copy_route' => … ) )) are dropped
* (2.4 security review L2).
*/
public function test_query_vars_passed_by_a_caller_are_dropped(): void {
$query = new WP_Query();
foreach ( array(
'post_type' => 'rc_item',
'orderby' => 'title',
'wpdo_post_copy_route' => array( 'post_type' => 'rc_item', 'group' => 'rc_core', 'statuses' => array( 'publish' ), 'order' => 'DESC, (SELECT 1)' ),
'wpdo_post_sort' => array( 'post_type' => 'rc_item', 'group' => 'rc_core', 'column' => 'rc_price', 'order' => 'ASC' ),
'wpdo_post_clauses' => array( 'rc_item' => array() ),
'wpdo_post_count_sql' => 'SELECT 1',
) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
foreach ( array( 'wpdo_post_copy_route', 'wpdo_post_sort', 'wpdo_post_clauses', 'wpdo_post_count_sql' ) as $var ) {
$this->assertEmpty( $query->get( $var ), $var );
}
$this->assertSame( 'wp_posts.post_title ASC', $router->posts_orderby( 'wp_posts.post_title ASC', $query ) );
}
/**
* wp_insert_post runs after transition_post_status: a new post's empty flat
* row must carry the copies itself, or one NULL turns the copy route off for
* the whole group (2.4 correctness review).
*/
public function test_a_new_post_gets_its_flat_row_with_the_copies(): void {
global $wpdb;
$post = new WP_Post( (object) array( 'ID' => 7, 'post_type' => 'rc_item', 'post_status' => 'draft', 'post_date' => '2026-02-02 00:00:00' ) );
$GLOBALS['_wp_posts'][7] = $post;
( new TMDO_Post_Query_Router() )->add_flat_rows( 7, $post, false );
$this->assertSame( array( 'post_status' => 'draft', 'post_date' => '2026-02-02 00:00:00' ), $wpdb->get_row( 'SELECT post_status, post_date FROM `' . self::TABLE . '` WHERE post_id = 7', ARRAY_A ) );
unset( $GLOBALS['_wp_posts'][7] );
}
public function test_copy_route_keeps_group_by_with_another_join(): void {
global $wpdb;
$sql = $this->request( " LEFT JOIN wp_term_relationships ON ({$wpdb->posts}.ID = wp_term_relationships.object_id)" );
$this->assertStringContainsString( "GROUP BY {$wpdb->posts}.ID", $sql );
}
/** @return array{join:string, where:string, orderby:string} */
private function sql( array $vars ): array {
$query = new WP_Query();
@@ -226,14 +226,14 @@ class PostQueryRouterFoundRowsTest extends TestCase {
*
* @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string}
*/
private function sorted( string $compare = '>=' ): array {
private function sorted( string $compare = '>=', string $order = 'DESC' ): array {
global $wpdb;
$query = new WP_Query();
$query->set( 'post_type', 'fr_item' );
$query->set( 'post_status', 'publish' );
$query->set( 'meta_query', array( 'price' => array( 'key' => 'fr_price', 'value' => 15, 'compare' => $compare, 'type' => 'NUMERIC' ) ) );
$query->set( 'orderby', 'price' );
$query->set( 'order', 'DESC' );
$query->set( 'order', $order );
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
@@ -252,11 +252,45 @@ class PostQueryRouterFoundRowsTest extends TestCase {
$this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql );
$this->assertStringContainsString( 'FROM `' . self::TABLE . '` AS `wpdoflat_fr_item_fr_core` INNER JOIN ' . $wpdb->posts . ' ON', $sql );
$this->assertStringNotContainsString( 'LEFT JOIN `' . self::TABLE . '`', $sql );
$this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id' );
$this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id, once' );
$this->assertSame( array( '3', '2' ), $wpdb->get_col( $sql ) );
$this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $request ) ), $wpdb->get_col( $sql ) );
}
/**
* The ID tie-break follows the sort direction, so a (column, post_id)
* index returns rows in order with no filesort (2.4 PR-118); ties are
* ordered by ID the same way as the column.
*/
public function test_flat_first_tie_break_follows_the_sort_direction(): void {
global $wpdb;
list( $router, $query, $request ) = $this->sorted( '>=', 'ASC' );
$sql = $router->posts_request( $request, $query );
$this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` ASC, `wpdoflat_fr_item_fr_core`.`post_id` ASC LIMIT 0, 10', $sql );
$this->assertSame( array( '2', '3' ), $wpdb->get_col( $sql ) );
}
/**
* Only standalone posts.ID terms are tie-breaks: inside an expression a
* plugin added (pinning posts with FIELD() or IN) the direction must not be
* inserted, or the SQL breaks (2.4 correctness review).
*/
public function test_flat_first_leaves_posts_id_inside_expressions_alone(): void {
global $wpdb;
list( $router, $query, $request ) = $this->sorted();
$pinned = str_replace( ' LIMIT 0, 10', ", FIELD({$wpdb->posts}.ID,3,2) DESC, ({$wpdb->posts}.ID IN (2)) DESC LIMIT 0, 10", $request );
$sql = $router->posts_request( $pinned, $query );
$this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql );
$this->assertStringContainsString( "FIELD({$wpdb->posts}.ID,3,2) DESC, ({$wpdb->posts}.ID IN (2)) DESC LIMIT", $sql );
$wpdb->last_error = '';
$this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $pinned ) ), $wpdb->get_col( $sql ) );
$this->assertSame( '', $wpdb->last_error );
}
public function test_date_ordered_filtered_request_keeps_wordpress_join_order(): void {
list( $router, $query, $request ) = $this->routed();
+122
View File
@@ -412,4 +412,126 @@ class PostQueryRouterTest extends TestCase {
$this->assertStringContainsString( "LIKE '%B1\\\\_1\\\\%%'", $router->posts_where( '', $query ) );
}
/**
* A clause with no value tests only that the key exists (WP_Meta_Query adds
* a value condition only when 'value' is set), so it routes as EXISTS.
* HivePress's featured-listing lookup on every search page is one:
* array( 'key' => 'hp_featured' ) — it kept the whole query native (2.4 PR-122).
*/
public function test_a_clause_without_a_value_routes_as_exists(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query(
array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_stock' ),
array( 'key' => '_price', 'value' => array( 10, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
),
)
);
$router->pre_get_posts( $query );
$this->assertEmpty( $query->get( 'meta_query' ), 'both clauses routed' );
$this->assertStringContainsString( '`_stock` IS NOT NULL', $router->posts_where( '', $query ) );
}
/**
* HivePress's featured-listing lookup (Attribute::set_search_query()): the
* search's meta query, including its sort group, plus meta_key hp_featured,
* ordered at random. The sort group (key NOT EXISTS OR key EXISTS) matches
* every post, so unused for ordering it is dropped; meta_key without a
* value or a meta_value sort tests that the key exists (2.4 PR-122).
*/
public function test_hivepress_featured_lookup_is_routed(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query(
array(
'post_type' => 'product',
'meta_key' => '_stock',
'orderby' => 'rand',
'meta_query' => array(
array(
'relation' => 'OR',
'price__order' => array( 'key' => '_price', 'compare' => 'NOT EXISTS', 'type' => 'DECIMAL' ),
array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'DECIMAL' ),
),
array( 'key' => '_price', 'value' => array( 10, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
),
)
);
$router->pre_get_posts( $query );
$this->assertSame( '', $query->get( 'meta_key' ), 'meta_key routed' );
$this->assertEmpty( array_filter( (array) $query->get( 'meta_query' ), 'is_array' ), 'sort group dropped, filter routed' );
$where = $router->posts_where( '', $query );
$this->assertStringContainsString( '`_stock` IS NOT NULL', $where );
$this->assertStringContainsString( 'BETWEEN', $where );
}
/**
* WordPress orders by the primary meta clause (meta_key) for meta_value,
* meta_value_num or the key's name anywhere in orderby; routing the key and
* clearing meta_key would drop that ordering (2.4 correctness review).
*/
public function test_meta_key_ordered_by_among_several_orderings_stays_native(): void {
foreach ( array(
array( 'meta_value_num' => 'ASC', 'title' => 'ASC' ),
'meta_value date',
'_stock',
) as $orderby ) {
$this->set_post_mode( 'aeav_only' );
$query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'orderby' => $orderby ) );
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$label = is_array( $orderby ) ? implode( ',', array_keys( $orderby ) ) : $orderby;
$this->assertSame( '_stock', $query->get( 'meta_key' ), $label );
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $label );
}
}
/** meta_compare_key LIKE matches other keys too: not a test on one flat column. */
public function test_meta_key_with_a_key_comparison_stays_native(): void {
foreach ( array( 'rand', 'meta_value_num' ) as $orderby ) {
$this->set_post_mode( 'aeav_only' );
$query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'meta_compare_key' => 'LIKE', 'orderby' => $orderby ) );
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertSame( '_stock', $query->get( 'meta_key' ), $orderby );
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $orderby );
}
}
/**
* Without meta_key the first clause is WordPress's primary one: orderby
* meta_value_num sorts by the sort group's key, so the group stays.
*/
public function test_sort_group_ordered_as_the_primary_clause_is_not_dropped(): void {
$this->assert_not_routed(
array(
'post_type' => 'product',
'orderby' => 'meta_value_num',
'meta_query' => array(
array(
'relation' => 'OR',
'price__order' => array( 'key' => '_price', 'compare' => 'NOT EXISTS', 'type' => 'DECIMAL' ),
array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'DECIMAL' ),
),
array( 'key' => '_stock', 'value' => 1, 'compare' => '>=', 'type' => 'NUMERIC' ),
),
),
'primary sort group'
);
}
public function test_meta_key_with_a_value_is_left_alone(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'meta_value' => '5' ) );
$router->pre_get_posts( $query );
$this->assertSame( '_stock', $query->get( 'meta_key' ) );
}
}
+98 -2
View File
@@ -25,6 +25,9 @@ class PostStatusSyncTest extends TestCase {
'includes/adapters/class-tmdo-adapter-post.php',
'includes/engine/class-tmdo-hook-bus.php',
'includes/engine/class-tmdo-post-status-sync.php',
'includes/engine/class-tmdo-entity-migration-engine.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/query/class-tmdo-post-query-router.php',
) as $file ) {
require_once $base . $file;
}
@@ -81,9 +84,34 @@ class PostStatusSyncTest extends TestCase {
return $out;
}
private function has_index( string $table ): bool {
/** @return array<string, string[]> index name → columns */
private function indexes( string $table ): array {
global $wpdb;
return in_array( 'idx_post_status_date', array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ), true );
$out = array();
foreach ( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ) as $row ) {
$out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name'];
}
return $out;
}
private function has_index( string $table ): bool {
return array( 'post_status', 'post_date', 'post_id' ) === ( $this->indexes( $table )['idx_post_status_date_id'] ?? null );
}
/**
* 2.4 PR-128: the date route's ID tie-break is read from post_id, which the
* index must end with; the 2.2 two-column index is replaced, not kept beside it.
*/
public function test_run_replaces_the_two_column_index_of_existing_tables(): void {
global $wpdb;
$wpdb->query( 'ALTER TABLE `' . self::A . '` ADD COLUMN post_status VARCHAR(20) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL, ADD COLUMN post_date DATETIME DEFAULT NULL, ADD INDEX idx_post_status_date (post_status, post_date)' );
$GLOBALS['_wp_options'][ TMDO_Post_Status_Sync::OPTION ] = array( 'ps_a' => 'ready' );
TMDO_Post_Status_Sync::run();
$this->assertTrue( $this->has_index( self::A ) );
$this->assertArrayNotHasKey( 'idx_post_status_date', $this->indexes( self::A ) );
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
}
public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void {
@@ -104,6 +132,16 @@ class PostStatusSyncTest extends TestCase {
$this->assertSame( 2, $report['ps_a']['mismatched'] );
}
/** Routed counts cached while copies were stale are dropped by a sync (2.4 correctness review). */
public function test_a_sync_invalidates_cached_routed_counts_but_a_dry_run_does_not(): void {
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
TMDO_Post_Status_Sync::run( true );
$this->assertSame( 'before', $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] );
TMDO_Post_Status_Sync::run();
$this->assertNotSame( 'before', $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] );
}
public function test_copies_use_the_posts_collation_so_they_compare_with_posts(): void {
global $wpdb;
TMDO_Post_Status_Sync::run();
@@ -173,6 +211,64 @@ class PostStatusSyncTest extends TestCase {
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
}
/**
* The migration engine (backfill, drift-check --fix) REPLACEs whole rows:
* without the copies, a rebuilt row had NULL post_status, which turns the
* status/date copy route off for the whole group (2.4 PR-127).
*/
public function test_a_row_rebuilt_from_native_keeps_its_copies(): void {
global $wpdb;
TMDO_Post_Status_Sync::run();
$GLOBALS['_wp_posts'][2] = (object) array( 'ID' => 2, 'post_type' => 'hp_listing', 'post_status' => 'draft', 'post_date' => '2026-02-01 10:00:00' );
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' );
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" );
try {
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native'), (3, 'k', 'a3-native')" );
WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ); // Existing row.
WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 3 ); // New row.
} finally {
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
}
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2] );
$this->assertSame( array( 'pending', '2026-03-01 10:00:00' ), $this->copies( self::A )[3] );
}
/**
* Backfills and drift-check --fix rebuild rows outside the Hook Bus: the
* routed counts cached for the old rows must go (2.4 PR-129; dev36: a
* vendor search kept counting 21515 of 21522 matches after a fix).
*/
public function test_rebuilding_post_rows_invalidates_cached_routed_counts(): void {
global $wpdb;
$status = $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status';
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" );
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$status}` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
$version = static fn() => $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] ?? null;
try {
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native')" );
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0, 'dry_run' => true ) );
$this->assertSame( 'before', $version(), 'a dry run writes nothing' );
foreach ( array(
'migrate_entity' => static fn() => TMDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ),
'migrate_group' => static fn() => TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0 ) ),
'migrate_group_batch' => static fn() => TMDO_Entity_Migration_Engine::migrate_group_batch( 'post', 'ps_a' ),
) as $path => $run ) {
TMDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'ps_a' );
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
$run();
$this->assertNotSame( 'before', $version(), $path );
}
} finally {
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$status}`" );
}
}
public function test_flat_write_that_creates_a_row_fills_the_copies(): void {
TMDO_Post_Status_Sync::run();
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
@@ -29,7 +29,8 @@ class ZoneArchiveIntegrationTest extends TestCase {
`original_meta_id` bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `post_id` (`post_id`),
KEY `archived_at` (`archived_at`)
KEY `archived_at` (`archived_at`),
KEY `idx_original_meta` (`original_meta_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
@@ -45,6 +46,69 @@ class ZoneArchiveIntegrationTest extends TestCase {
$GLOBALS['_wp_postmeta'] = [];
}
// ── sweep / duplicates (2.4.2 PR-136, manual F-6) ─────────────────────
/** Posts and postmeta for sweep(): post 10 trashed 200 days ago, post 11 published. */
private function seed_trashed_post(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_modified_gmt DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
$wpdb->query( "CREATE TABLE `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" );
$old = gmdate( 'Y-m-d H:i:s', time() - 200 * DAY_IN_SECONDS );
$wpdb->query( "INSERT INTO `{$wpdb->posts}` VALUES (10, 'hp_listing', 'trash', '{$old}'), (11, 'hp_listing', 'publish', '{$old}')" );
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (10, 'hp_price', '5'), (10, 'hp_rating', '4'), (11, 'hp_price', '9')" );
}
private function drop_posts(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
}
private function archive_rows(): int {
global $wpdb;
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' );
}
public function test_sweep_copies_each_trashed_meta_row_once(): void {
$this->seed_trashed_post();
try {
$this->assertSame( 2, WPDO_Zone_Archive::sweep( 90, false ) );
$this->assertSame( 0, WPDO_Zone_Archive::sweep( 90, false ), 'already archived' );
$this->assertSame( 2, $this->archive_rows() );
} finally {
$this->drop_posts();
}
}
/** Until `wp tmdo install` adds the index, NOT EXISTS would scan the archive per candidate. */
public function test_sweep_waits_for_the_original_meta_index(): void {
global $wpdb;
$this->seed_trashed_post();
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` DROP INDEX `idx_original_meta`' );
try {
$this->assertSame( 0, WPDO_Zone_Archive::sweep( 90, false ) );
$this->assertSame( 0, $this->archive_rows() );
} finally {
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` ADD KEY `idx_original_meta` (`original_meta_id`)' );
$this->drop_posts();
}
}
public function test_remove_duplicates_keeps_the_first_copy_of_each_meta_row(): void {
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 );
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 );
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 );
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 );
WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' ); // original_meta_id 0: no identity, kept.
WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' );
$this->assertSame( 2, WPDO_Zone_Archive::remove_duplicates() );
$this->assertSame( 4, $this->archive_rows() );
$this->assertSame( 0, WPDO_Zone_Archive::remove_duplicates() );
}
// ── archive / get ─────────────────────────────────────────────────────
public function test_archive_stores_plain_entry(): void {
+14
View File
@@ -315,6 +315,7 @@ if ( ! function_exists( 'get_metadata' ) ) {
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } }
if ( ! function_exists( 'wp_unschedule_hook' ) ) { function wp_unschedule_hook( string $hook ): int|false { $GLOBALS['_wp_unscheduled'][] = $hook; return 0; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } }
if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } }
if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } }
@@ -354,6 +355,19 @@ if ( ! function_exists( 'get_term_meta' ) ) { function get_term_meta( int $tid,
if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } }
if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } }
if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } }
if ( ! class_exists( 'WP_Post' ) ) {
final class WP_Post {
public $ID = 0;
public $post_type = 'post';
public $post_status = 'publish';
public $post_date = '0000-00-00 00:00:00';
public function __construct( $post ) {
foreach ( get_object_vars( $post ) as $k => $v ) {
$this->$k = $v;
}
}
}
}
if ( ! function_exists( 'get_post' ) ) { function get_post( $post_id ) { return $GLOBALS['_wp_posts'][ (int) $post_id ] ?? null; } }
if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* The admin settings form saves bridge modes through TMDO_Mode_Manager::set().
* A refused change (unsafe jump, Promotion Guard) used to be dropped silently:
* the page said "saved" while the mode stayed put (2.4.2 PR-135, manual F-2).
*/
class AdminBridgeModeSaveTest extends TestCase {
public static function setUpBeforeClass(): void {
require_once TMDO_PATH . 'admin/class-tmdo-admin.php';
}
protected function setUp(): void {
ini_set( 'error_log', '/dev/null' );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_filter_callbacks'] = array();
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
$GLOBALS['_wp_current_user_id'] = 7;
TMDO_Mode_Manager::reset_cache();
}
private function modes( array $modes ): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes;
TMDO_Mode_Manager::reset_cache();
}
public function test_an_accepted_change_is_applied_without_refusals(): void {
$this->modes( array( 'user' => 'disabled' ) );
$refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'dual_write' ) );
$this->assertSame( array(), $refused );
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) );
}
public function test_an_unsafe_jump_is_reported_and_the_mode_kept(): void {
$this->modes( array( 'post' => 'disabled' ) );
$refused = TMDO_Admin::apply_bridge_modes( array( 'post' => 'shadow_read' ) );
$this->assertCount( 1, $refused );
$this->assertStringStartsWith( 'post', $refused[0] );
$this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'post' ) );
}
public function test_empty_or_unknown_modes_are_ignored(): void {
$this->modes( array( 'term' => 'disabled' ) );
$this->assertSame( array(), TMDO_Admin::apply_bridge_modes( array( 'post' => '', 'term' => 'bogus' ) ) );
$this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'term' ) );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_a_promotion_refused_by_the_guard_is_reported(): void {
eval( 'namespace HivePress; class Core {}' );
$this->modes( array( 'user' => 'shadow_read' ) );
$refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'aeav_only' ) );
$this->assertCount( 1, $refused );
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) );
}
/** The full-page form redirects after saving: refusals wait in a per-user transient, shown once. */
public function test_refusals_are_kept_for_the_next_page_view_only(): void {
TMDO_Admin::remember_bridge_mode_refusals( array( 'post:refused' ) );
$this->assertSame( array( 'post:refused' ), TMDO_Admin::pull_bridge_mode_refusals() );
$this->assertSame( array(), TMDO_Admin::pull_bridge_mode_refusals() );
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Backfill batches are scheduled per (entity, group) — REST backfill and
* TMDO_Core re-chain `wpdo_entity_backfill_batch` with both args. The health
* panel looked for (entity) alone, never found a running backfill and kept
* offering to start one; starting one resets the group's checkpoint
* (2.4.2 PR-137, manual F-11).
*/
class EntityHealthBackfillTest extends TestCase {
protected function setUp(): void {
$GLOBALS['_wp_next_scheduled'] = array();
TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
TMDO_Entity_Registry::register_group( 'user', 'eh_one', array( array( 'key' => 'eh_a', 'type' => 'text' ) ) );
TMDO_Entity_Registry::register_group( 'user', 'eh_two', array( array( 'key' => 'eh_b', 'type' => 'text' ) ) );
}
protected function tearDown(): void {
$GLOBALS['_wp_next_scheduled'] = array();
TMDO_Entity_Registry::init();
}
private function backfill_active( string $entity_type ): bool {
$m = new ReflectionMethod( TMDO_Entity_Health::class, 'is_backfill_active' );
$m->setAccessible( true );
return (bool) $m->invoke( null, $entity_type );
}
public function test_a_backfill_scheduled_for_one_group_is_seen(): void {
$GLOBALS['_wp_next_scheduled']['wpdo_entity_backfill_batch'][ md5( serialize( array( 'user', 'eh_two' ) ) ) ] = time() + 5;
$this->assertTrue( $this->backfill_active( 'user' ) );
$this->assertFalse( $this->backfill_active( 'post' ), 'other entities are not affected' );
}
public function test_no_scheduled_batch_means_no_backfill(): void {
$this->assertFalse( $this->backfill_active( 'user' ) );
}
}
+13 -1
View File
@@ -14,7 +14,9 @@ class EntityRegistryIndexesTest extends TestCase {
protected function setUp(): void {
TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
$adapter = $this->createMock( TMDO_Entity_Adapter_Interface::class );
$adapter->method( 'get_entity_id_column' )->willReturn( 'post_id' );
TMDO_Entity_Registry::register_adapter( 'post', $adapter );
}
private function fields(): array {
@@ -37,6 +39,16 @@ class EntityRegistryIndexesTest extends TestCase {
$this->assertSame( array( 'ok' => array( 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
}
/**
* A sort index ends with the entity ID, so a flat-first search ordered by a
* column with the ID tie-break reads the index in order (2.4 PR-118).
*/
public function test_an_index_may_end_with_the_entity_id_column(): void {
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'price_id' => array( 'x_price', 'post_id' ), 'id_first' => array( 'post_id', 'x_price' ) ) ) );
$this->assertSame( array( 'price_id' => array( 'x_price', 'post_id' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ), 'the ID only as the last column' );
}
public function test_indexes_leave_the_field_definition_and_hash_alone(): void {
TMDO_Entity_Registry::register_group( 'post', 'plain', $this->fields() );
TMDO_Entity_Registry::register_group( 'post', 'indexed', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) );
+1 -1
View File
@@ -94,7 +94,7 @@ if ( $wpdo_blocked ) {
wp_die(
'<p>' . esc_html( '2meet Data Optimizer 已停止解除安裝:以下資料只存在 wpdo_* 表,刪除外掛會一併刪除這些表。' ) . '</p>'
. '<ul><li>' . implode( '</li><li>', array_map( 'esc_html', $wpdo_blocked ) ) . '</li></ul>'
. '<p>' . esc_html( '目前沒有把這些資料寫回 WordPress 原生表的自動工具。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '</p>'
. '<p>' . esc_html( '請先以 wp tmdo bridge-restore-native 把資料寫回 WordPress 原生表,再解除安裝。若只想移除外掛程式,可手動刪除外掛目錄,資料表會保留。' ) . '</p>'
);
}