diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index d6d1a18..de1693f 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -3,7 +3,7 @@ * Plugin Name: 2meet Data Optimizer * Plugin URI: https://2meet.io/2meet-data-optimizer * Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。 - * Version: 1.0.2 + * Version: 1.1.0 * Requires at least: 6.0 * Tested up to: 6.9.4 * Requires PHP: 8.1 @@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // ── Constants ────────────────────────────────────────────────────────────── -define( 'TMDO_VERSION', '1.0.2' ); +define( 'TMDO_VERSION', '1.1.0' ); define( 'TMDO_DB_VERSION', '2.1.0' ); define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_URL', plugin_dir_url( __FILE__ ) ); @@ -234,6 +234,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php'; // ── v2.0.0 Engine + Adapters + Modules ─────────────────────────────────── require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php'; @@ -260,6 +261,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php'; add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) ); add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) ); add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) ); @@ -292,6 +294,8 @@ if ( is_admin() ) { TMDO_Deprecated_Addons_Notice::register(); require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php'; TMDO_Missing_Addons_Notice::register(); + require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php'; + TMDO_Promotion_Guard_Notice::register(); } // ── WP-CLI ───────────────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa3a21..227350e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,60 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [1.1.0] — 2026-09-26 — HivePress / WooCommerce 搜尋索引模式(hardening 2.1) + +**性質**:資料正確性修正 + 查詢改寫補強。無 schema 變更。決策與設計見 +`docs/adr-004-search-index-mode-for-hp-wc.md`;搭配 HivePress AddOn 0.2.0。 + +### Added + +- **Promotion Guard**(`TMDO_Promotion_Guard`):HivePress 啟用時 post / user / term / + comment、WooCommerce 啟用時 post / user 不得升到 aeav_only 或執行 cleanup + (`WP_Error( 'promotion_guarded' )`,filter `tmdo_promotion_guard_allow`);已在 + aeav_only 的站只顯示後台 notice。(PR-053) +- **`wp tmdo repair-array-literals [--entity] [--confirm]`**:把扁平表中的 `"Array"` + 從原生修回;原生也被污染的列出清單。(PR-055) +- **`bridge-restore-native --overwrite / --only-missing`**:預設依模式(aeav_only 覆寫、 + 其他只補缺);報告新增 `missing` / `differs` / `same` / `default_skipped`。(PR-058) +- **live 測試層** `tests/live/`(在真實站台以 `wp eval-file` 執行):suites + `shadow_write`、`serialized`、`resync`、`router`、`zero_side_effect`,以及 qbench / + fe.sh 等量測工具。(PR-052) + +### Changed + +- **Hook Bus 寫入順序**:dual_write / shadow_read 由 WordPress 寫原生,扁平表在 + `added_ / updated_ / deleted_{type}_meta` 從原生重新同步(含 `delete_all`、 + `update_metadata_by_mid` 改 key)。(PR-057) +- **shadow_read** 讀原生,並抽樣比對扁平值(`tmdo_shadow_compare_sample_rate`)。(PR-056) +- **Post query router**: + - 全有或全無:OR、多 post type、含已註冊 key 的巢狀 group、type 語意與欄位不符的 + clause 讓整個查詢維持原生;LIKE 包 `%esc_like%`;`pre_get_posts` priority 1000。(PR-060) + - 每個 group 各自 JOIN、不加 GROUP BY;NOT EXISTS 不走狀態 / 日期副本路由。(PR-061) + - meta orderby:HivePress `_sort`(NOT EXISTS + EXISTS)形狀、具名 clause、 + `meta_key` + `meta_value_num`。(PR-062) + - routed 搜尋維持 WordPress 的 split query(ID-first)。(PR-062b) + - 依扁平欄位排序且有篩選的搜尋改由扁平表驅動(`STRAIGHT_JOIN`,posts.ID tie-break 改讀等值的 + `post_id`),不再以 temporary table 排序;filter `tmdo_router_flat_first` 可關閉。(PR-062c) +- `composer phpcs` 改用 `phpcs.xml`(與 CI 相同)。 + +### Fixed + +- shadow_read 下 `update_*_meta()` 回傳 false、原生沒有寫入(資料遺失)。(PR-056) +- 陣列 / 物件存進 text 欄位變成 `"Array"`;text 超過 255 字元改存 NULL 並記錄 + `text_overflow`。(PR-055) +- `bridge-restore-native` 覆蓋較新的原生值(WC 庫存)、替每個 user 捏造預設值列。(PR-058) +- Cold / Hot 表不存在時,`save_post`、`loop_start`、刪除文章仍查詢它們(DB error)。(PR-054、PR-054b) +- 刪除 post / user / term / comment 後,扁平表留下全 NULL 的孤兒列(PR-057 的 NULL 同步會重建已刪的列)。(PR-057b) +- Router:Q05(wp_core 與另一 group 的 key 並用)`Unknown column`;OR / 多 post type / + 無 type 數值比較 / 具名 orderby 的結果與原生不同。(PR-060〜062) + +### Docs + +- ADR-004、`CONTEXT.md` Entity Bridge(寫入順序、shadow_read、搜尋索引模式、Router 規則)、 + `docs/BENCHMARK-SCALE.md` 前台與寫入鏈實測。(PR-064) + +--- + ## [1.0.2] — 2026-08-15 — NinjaFirewall 相容性:WAF 設定選項保護 **性質**:防禦性修正 + 文件。無 schema 變更,無破壞性變更。 diff --git a/CONTEXT.md b/CONTEXT.md index d469fd3..567d15a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -135,6 +135,29 @@ Newer anti-EAV system (v2.5+) covering user, term, comment, and post entities vi The Entity Bridge and the Zone system coexist; the Sync Bridge's `is_owned_by_entity_bridge()` guard prevents duplicate writes. +**Write order (v1.1.0).** In `dual_write` / `shadow_read` the Hook Bus pre-filters +only decide the route and return `null`: WordPress writes native meta itself, and the +flat value is then re-synced *from native* in `added_/updated_/deleted_{type}_meta` +(priority 1) — `TMDO_Hook_Bus::sync_from_native()` reads the first native row with +direct SQL; no native row → flat NULL. `aeav_only` keeps the pre-filter short-circuit. + +**shadow_read** reads native meta (WordPress meta cache) and compares a sample of reads +with the flat value (`tmdo_shadow_compare_sample_rate`, default 0.1). Flat tables serve +queries only: `TMDO_Post_Query_Router` is active from `shadow_read` up. + +**Search-index mode** (ADR-004). On HivePress / WooCommerce sites native meta is the +source of truth and flat tables are its search index: `TMDO_Promotion_Guard` refuses +`aeav_only` and cleanup there (post/user/term/comment with HivePress, post/user with +WooCommerce), because both read meta with an empty key and WooCommerce updates stock +and sales with direct SQL. Override: filter `tmdo_promotion_guard_allow`. + +**Post query router rules.** A query is rewritten only when the flat table returns +what native `meta_query` would: every registered clause routable (else the whole query +stays native), AND relation, one post type, column type matching the clause's CAST +semantics, LIKE as `%esc_like%`. Meta ordering (the HivePress `_sort` NOT EXISTS/EXISTS +shape, a named clause, `meta_key` + `meta_value_num`) is routed only as the sole +`orderby` on a column that orders like native. + --- ## Migration Phase (Strategy pattern) diff --git a/admin/class-tmdo-promotion-guard-notice.php b/admin/class-tmdo-promotion-guard-notice.php new file mode 100644 index 0000000..c5e6cb9 --- /dev/null +++ b/admin/class-tmdo-promotion-guard-notice.php @@ -0,0 +1,63 @@ + $integrations ) { + $lines[] = sprintf( '%s(%s):wp tmdo bridge-restore-native %s --demote', $entity_type, implode( ' / ', $integrations ), $entity_type ); + } + printf( + '

%s %s

', + esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ), + esc_html( + sprintf( + /* translators: %s: "entity (integrations): command" list */ + __( '以下實體處於 aeav_only,但這些外掛會直接讀取原生 meta,可能讀到過期或遺失的資料。請先備份,再把扁平資料寫回原生並降級:%s', '2meet-data-optimizer' ), + implode( ';', $lines ) + ) + ) + ); + } +} diff --git a/cli/class-tmdo-cli-v2.php b/cli/class-tmdo-cli-v2.php index b3d76f2..fe6c07e 100644 --- a/cli/class-tmdo-cli-v2.php +++ b/cli/class-tmdo-cli-v2.php @@ -162,7 +162,15 @@ class TMDO_CLI_V2 { * : post | user | term | comment * * [--dry-run] - * : Count the flat rows per group; write nothing. + * : Count the flat rows per group and how their values compare with native + * meta (missing / differs / same / default skipped); write nothing. + * + * [--overwrite] + * : Flat values replace differing native values. Default in aeav_only. + * + * [--only-missing] + * : Only fill native rows that do not exist; never replace. Default in every + * other mode, where native meta is the source of truth. * * [--batch=] * : Rows per batch. Default 500. @@ -190,17 +198,34 @@ class TMDO_CLI_V2 { public function bridge_restore_native( $args, $assoc_args ): void { $entity = (string) ( $args[0] ?? '' ); if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) { - WP_CLI::error( 'Usage: wp tmdo bridge-restore-native [--dry-run] [--batch=] [--demote] [--group= [--retire]]' ); + WP_CLI::error( 'Usage: wp tmdo bridge-restore-native [--dry-run] [--overwrite|--only-missing] [--batch=] [--demote] [--group= [--retire]]' ); } $dry_run = isset( $assoc_args['dry-run'] ); $group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) ); if ( isset( $assoc_args['retire'] ) && '' === $group ) { WP_CLI::error( '--retire needs --group=.' ); } - $report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group ); + if ( isset( $assoc_args['overwrite'] ) && isset( $assoc_args['only-missing'] ) ) { + WP_CLI::error( 'Use either --overwrite or --only-missing, not both.' ); + } + $policy = isset( $assoc_args['overwrite'] ) ? 'overwrite' : ( isset( $assoc_args['only-missing'] ) ? 'only_missing' : '' ); + $report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group, $policy ); + WP_CLI::log( "Policy: {$report['policy']}" ); foreach ( $report['groups'] as $group => $g ) { - WP_CLI::log( sprintf( '%s/%s: %d row(s)%s', $entity, $group, $g['rows'], $dry_run ? '' : sprintf( ', %d native value(s) written', $g['written'] ) ) ); + WP_CLI::log( + sprintf( + '%s/%s: %d row(s) — native missing %d, differs %d, same %d, default skipped %d%s', + $entity, + $group, + $g['rows'], + $g['missing'], + $g['differs'], + $g['same'], + $g['default_skipped'], + $dry_run ? '' : sprintf( '; %d native value(s) written', $g['written'] ) + ) + ); } if ( $dry_run ) { WP_CLI::success( '[dry-run] Nothing written.' ); @@ -217,6 +242,50 @@ class TMDO_CLI_V2 { WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' ); } + /** + * Find flat text / textarea values stored as the literal "Array" (pre-1.1.0 + * type caster) and rewrite them from native meta. + * + * Native rows that are "Array" too cannot be repaired here; they are listed + * so they can be restored from a backup. + * + * ## OPTIONS + * + * [--entity=] + * : post | user | term | comment. Default: all four. + * + * [--confirm] + * : Write the repairs. Without it the command only reports. + * + * ## EXAMPLES + * + * wp tmdo repair-array-literals + * wp tmdo repair-array-literals --entity=user --confirm + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function repair_array_literals( $args, $assoc_args ): void { + $entities = isset( $assoc_args['entity'] ) ? array( (string) $assoc_args['entity'] ) : TMDO_Entity_Registry::VALID_ENTITY_TYPES; + $apply = isset( $assoc_args['confirm'] ); + + foreach ( $entities as $entity ) { + if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) { + WP_CLI::error( "Unknown entity: {$entity}" ); + } + $report = TMDO_Array_Literal_Repair::run( $entity, $apply ); + WP_CLI::log( sprintf( '%s: %d "Array" value(s) in flat tables, %d repairable from native, %d repaired.', $entity, $report['found'], $report['repairable'], $report['repaired'] ) ); + foreach ( $report['unrepairable'] as $row ) { + WP_CLI::log( sprintf( ' unrepairable %s/%s #%d (%s)', $row['group'], $row['key'], $row['id'], $row['reason'] ) ); + } + foreach ( $report['native_polluted'] as $row ) { + WP_CLI::warning( sprintf( 'native meta is "Array": %s %s #%d — restore it from a backup', $entity, $row['key'], $row['id'] ) ); + } + } + + WP_CLI::success( $apply ? 'Repair finished.' : '[dry-run] Nothing written. Re-run with --confirm to repair.' ); + } + /** * Audit per-module state + shadow_read flags. * @@ -851,6 +920,8 @@ WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) ); WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) ); +WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) ); +WP_CLI::add_command( 'tmdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) ); WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) ); WP_CLI::add_command( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) ); WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) ); diff --git a/composer.json b/composer.json index 205ebb2..6d6d3ad 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "scripts": { "test": "phpunit --configuration phpunit.xml", "test:integration": "phpunit --configuration phpunit-integration.xml", - "phpcs": "phpcs --standard=WordPress includes/ admin/ cli/ modules/ 2meet-data-optimizer.php uninstall.php", + "phpcs": "phpcs --standard=phpcs.xml", "stan": "phpstan analyse --no-progress", "stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon" }, diff --git a/docs/BENCHMARK-SCALE.md b/docs/BENCHMARK-SCALE.md index 3eb6066..dd0e712 100644 --- a/docs/BENCHMARK-SCALE.md +++ b/docs/BENCHMARK-SCALE.md @@ -63,6 +63,41 @@ SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個 6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。 7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。 +## 前台與寫入鏈實測(dev36,Release 2.1,2026-09-26) + +上面的數字是純 SQL。這一節是真實站台(dev36:HivePress 1.7.31 + Marketplace、WooCommerce 11.1.2、Redis object cache、listing 約 10 萬筆)上的整頁與整條寫入鏈,決策背景見 `adr-004-search-index-mode-for-hp-wc.md`。 + +**前台搜尋**(`tests/live/tools/fe.sh`:每個 URL 5 次取中位數,`X-S36-Ms` = 整個請求到 `template_redirect`)。「routed」是 post 模式 shadow_read + 核心 2.1 router + HivePress AddOn 0.2.0;「1.0.x」是整合測試當時的 router(報告 `notes/tmdo-integration-test-dev36-2026-09-26.md`)。 + +| URL | 原生 ms | 1.0.x ms | 2.1 routed ms | ids 與原生相同 | +|---|---:|---:|---:|---| +| listing 價格區間 | 614 | 496 | **323** | 是 | +| listing 價格區間 + `_sort=price__asc` | 975 | 1,062 | **435** | 是 | +| listing 時薪區間 + `_sort=hourly_rate__desc` | 850 | — | **367** | 是 | +| listing `_sort=rating__desc` | 1,697 | 1,720 | **866** | 是 | +| listing 關鍵字 + 價格區間 | 754 | — | 764 | 是 | +| vendor 時薪區間 | 102 | — | 97 | 是 | +| WC 商店價格篩選 / 價格排序 | 94 / 111 | 88–94 | 109 / 88 | 是(WC 用 lookup 表,不經 router) | + +(R-2.1 驗收,扁平表已從原生回填、含 PR-062c;兩輪量測見 `notes/tmdo-acceptance-2.1-2026-09-26.md`。) + +- HivePress 搜尋仍帶 tax_query(排除一個 term),WordPress 因此加上 `GROUP BY posts.ID`,排序需要 temporary + filesort;這是目前 311–428 ms 的主要成本,尚未達到 R-2.1 的 300 ms 目標。 +- PR-062b 之前,routed 搜尋因 `posts_request` 改寫而失去 WordPress 的 split query,改撈整列 `posts.*`:同一條 SQL 撈 `*` 要 590 ms,只撈 ID 為 312 ms。 +- PR-062c 之前,依扁平欄位排序的 routed 查詢由 `wp_posts`(type / status 索引,約 56k 列)驅動、逐筆查扁平表再以 temporary table 排序;拿掉 GROUP BY 或只換 tie-break 都無效(約 300 ms),改由扁平表驅動並把 tie-break 改讀 `post_id` 後只剩單表 filesort:`meta_value_num` 263→147 ms、具名排序 250→142 ms、HP 時薪篩選 + 排序 300→36 ms(只計 SQL)。依日期排序的搜尋由扁平表驅動反而變慢(74→137 ms),所以 flat-first 只用在扁平欄位排序。 +- 量測 gotcha:symlink 切換(`plugins/` → `plugins-dev`)後,PHP-FPM 的 opcache(`revalidate_path=0`)仍執行舊目標的程式,WP-CLI 不受影響;前台數字要先確認實際載入的檔案。 + +**WP_Query**(`tests/live/tools/qbench.php`,`fields=ids`,20 筆一頁):價格區間 296→76 ms、價格 + verified 360→53 ms、rating ≥ 4.5 248→33 ms、rate + `_thumbnail_id` EXISTS 24→9 ms;OR、多 post type、無 type 數值比較等維持原生,結果與原生相同。 + +**寫入鏈開銷**(單一程序,disabled 與 dual_write 交替兩輪取平均): + +| 寫入 | disabled | dual_write | 增加 | +|---|---:|---:|---:| +| HP listing | 754 ms / 348 q | 976 ms / 434 q | +29% | +| WC product | 215 ms / 129 q | 489 ms / 206 q | +127% | +| vendor(user + post) | 444 ms / 162 q | 630 ms / 230 q | +42% | + +WC product 的成本來自 `wc_product` 群組,Release 2.2 移除 WC 扁平化後消失。 + ## 優化候選(待使用者確認後排入 backlog) | # | 候選 | 針對 | 預期效果 | diff --git a/docs/adr-004-search-index-mode-for-hp-wc.md b/docs/adr-004-search-index-mode-for-hp-wc.md new file mode 100644 index 0000000..3aaad77 --- /dev/null +++ b/docs/adr-004-search-index-mode-for-hp-wc.md @@ -0,0 +1,100 @@ +# ADR-004:HivePress / WooCommerce 站採「搜尋索引模式」 + +- **狀態**:已接受(2026-09-26) +- **日期**:2026-09-26 +- **相關**:dev36 整合測試報告(`notes/tmdo-integration-test-dev36-2026-09-26.md`)、ADR-001、ADR-003、backlog PR-052〜PR-063(Release 2.1) + +## 背景 + +2026-09-26 在 dev36(HivePress 1.7.31 + Marketplace + 10 個擴充、WooCommerce 11.1.2、Redis、10 萬筆 listing)做整合與壓力測試,確認: + +1. **HivePress model 與 WC data store 用空 key 讀 meta**(`get_post_meta( $id )`),1.0.x 的 Hook Bus 對空 key 放行、只讀原生。所以 aeav_only 或 cleanup 之後,HP 的 verified / rating、WC 的價格與顧客資料讀成空值(dev20 / dev21 盤點:WC 商品 0/20 可購買)。 +2. **WC 以直接 SQL 更新 `_stock`、`total_sales`**,扁平表跟不上。 +3. **shadow_read 會遺失寫入**:WP 的 `update_metadata()` 經 `get_metadata_raw` 讀舊值,攔截器從扁平表回答,新舊相同就不寫原生。 +4. **Router 在 OR、LIKE、多 post type、跨 group、具名 orderby 上結果錯誤**,HivePress 的 `_sort` 沒被改寫,前台搜尋幾乎沒有變快。 +5. **陣列值被存成 `"Array"`**。 + +帶 key 的單筆讀取本來就沒有速度優勢:`update_meta_cache()` 一次載入全部 key,之後都是記憶體讀取。扁平表真正的價值在**搜尋與篩選**(`BENCHMARK-SCALE.md` 結論 1、2)。 + +## 決策(使用者 2026-09-26) + +1. **HP / WC 站採「搜尋索引模式」**:原生 meta 是唯一真實資料來源,扁平表是它的搜尋索引。模式最高到 `shadow_read`;`aeav_only` 與 cleanup 由 Promotion Guard 擋下。 +2. **移除 WC 扁平化**(Release 2.2:core 的 `post/wc_product` 群組、WC AddOn 的 user `hot` 群組與 hot-zone 欄位)。 +3. **既有 aeav_only 站(dev20 / 21 / 22)先唯讀盤點**,處置由使用者決定(`notes/ops-aeav-sites-audit-2026-09-26.md`)。 +4. **分兩版**:2.1 = 止血 + 資料正確性 + Router 與 HP 排序;2.2 = 範圍收斂 + 漂移偵測 + CLI 與診斷。 + +## 設計(Release 2.1) + +### 寫入:扁平表跟隨原生 + +- **dual_write / shadow_read** 下,pre-filter(`update_/add_/delete_{type}_metadata`)只做路由判斷後回 `null`,讓 WordPress 自己寫原生。 +- 扁平表在 `added_ / updated_ / deleted_{type}_meta` action(priority 1)裡**從原生重新同步**:直接 SQL 讀原生第一筆 row(`ORDER BY meta_id LIMIT 1`,不經 filter),扁平值 = `to_db( maybe_unserialize( v ) )`,原生沒有 row 時設為 NULL(`TMDO_Hook_Bus::sync_from_native()`)。 +- `delete_metadata( …, $delete_all )`:`deleted_` action 拿到的 object id 是 0,所以在 `delete_{type}_meta` 先記下 object ids。 +- `update_metadata_by_mid()` 改 key:新舊兩個 key 都重新同步。 +- 原生沒有值時只把既有列的欄位設為 NULL,**不新增列**:刪除實體時 adapter 先刪扁平列,WordPress 才逐筆刪 meta。 +- **aeav_only** 保留原本的 pre-filter short-circuit(非 HP / WC 站仍可使用)。 + +### 讀取:shadow_read = 讀原生、抽樣比對扁平 + +`intercept_get` 在 shadow_read 回 `$check`(讓 WordPress 讀原生),並以 `tmdo_shadow_compare_sample_rate`(預設 0.1)抽樣比對扁平值、記錄差異。查詢加速不受影響,因為 Router 仍由 `reads_from_flat()`(shadow_read 以上)控制。 + +### Promotion Guard + +`TMDO_Promotion_Guard`,由 `TMDO_Mode_Manager::set()`、`TMDO_Post_Migration::cleanup()` 與 `TMDO_Phase_Cleanup::execute()` 呼叫: + +- HivePress 啟用時,post / user / term / comment 不得升到 aeav_only 或執行 cleanup;WooCommerce 啟用時,post / user 不得。 +- 回傳 `WP_Error( 'promotion_guarded' )`;特殊情況用 filter `tmdo_promotion_guard_allow` 放行。 +- 已經在 aeav_only 的站**讀取時不強制降級**(降級會讀到空的原生),只在後台顯示 notice。處置用 `wp tmdo bridge-restore-native --demote`。 + +### 回寫原生(bridge-restore-native) + +依模式決定策略:aeav_only 預設 `overwrite`(寫入只到扁平表、原生已過期),其他模式預設 `only_missing`(原生可能較新,例如 WC 直接 SQL 寫庫存)。相同的值不重寫;扁平值等於欄位 default 且原生沒有 row 時不寫(不捏造預設值)。 + +### 序列化值 + +`text` / `textarea` 遇到陣列或物件時 `maybe_serialize()`,讀出時 `TMDO_Safe_Unserialize`;`text`(VARCHAR(255))超長存 NULL 並記 `text_overflow`。既有的 `"Array"` 由 `wp tmdo repair-array-literals` 從原生修復。 + +### Router:只在語意與原生相同時路由 + +`TMDO_Post_Query_Router`(`pre_get_posts` priority 1000,排在外掛於預設 priority 加的條件之後): + +- **全有或全無**:任何一個已註冊 key 的 clause 不能路由,整個查詢交回 WordPress。不能路由的情況: + - top-level OR + - 含已註冊 key 的巢狀 group(排序形狀除外) + - 多個 post type + - 白名單以外的 compare + - type 與欄位語意不同:無 type(CHAR)的數值範圍比較、decimal 欄位的 CHAR 比較、text 欄位用 NUMERIC +- **LIKE** 包成 `%esc_like%`。 +- **每個 group 各自 LEFT JOIN**;扁平表一文章一列(UNIQUE `post_id`),不加 GROUP BY。 +- 狀態 / 日期副本路由遇到 NOT EXISTS 時停用:INNER JOIN 會漏掉沒有扁平列的文章。 +- **排序**(`wpdo_post_sort`): + - **HivePress `_sort` 形狀**:OR 裡剛好是同 key、同 type 的 NOT EXISTS + EXISTS,其中一個具名,並且是唯一的 orderby。這個 group 從 meta_query 移除,改成 LEFT JOIN 扁平表並 `ORDER BY col, ID DESC`;ASC 時 NULL 排最前,與原生相同。 + - **具名 clause** 作為唯一的 orderby。 + - **`meta_key` + `meta_value_num`**(字串欄位用 `meta_value`),加上 `col IS NOT NULL`(原生是 INNER JOIN)。 + - 欄位排序不等於 `CAST( meta_value AS type )` 時退回原生。 +- **flat-first**:依扁平欄位排序、且 routed 條件已要求扁平列存在(同一 group、非 NOT EXISTS)、沒有 `post__in` / `author` 等高選擇性條件時,改寫成 `SELECT STRAIGHT_JOIN … FROM flat INNER JOIN wp_posts`,ORDER BY 的 `wp_posts.ID` 改讀等值的 `post_id`,排序只在一張表完成。依日期排序的搜尋不套用(由 posts 驅動較快)。filter `tmdo_router_flat_first` 可關閉。 +- **維持 WordPress 的 split query**:`posts_request` 移除 `SQL_CALC_FOUND_ROWS`(總數改由快取的 COUNT 回答)會讓 WordPress 以為 request 被改過而撈整列。`split_the_query` 對 routed 查詢恢復 ID-first,`posts_request_ids` 再移除一次 `SQL_CALC_FOUND_ROWS`。 + +HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `optimize_query` / `optimize_search`。 + +## 後果 + +**好處** + +- 資料正確性回到 WordPress 原生語意:HP / WC 的空 key 讀取、直接 SQL 寫入都不再造成資料不一致或遺失。 +- 查詢加速保留,而且擴及 HivePress 前台的篩選與排序(實測見 `BENCHMARK-SCALE.md`「前台與寫入鏈實測」)。 +- 任何時候都可以降回 disabled,原生資料完整。 + +**代價與限制** + +- **寫入開銷**:dual_write / shadow_read 每次 meta 寫入多一次原生讀取與一次扁平寫入。dev36 實測 HP listing 整條寫入鏈 +29%、WC product +127%(後者在 2.2 移除 WC 扁平化後消失)。 +- **沒有讀取加速**:帶 key 的單筆讀取仍走原生(WordPress meta cache)。 +- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 的 `drift-check` 與重新啟用時的增量 backfill(E01 / E02)處理;在那之前,大量匯入後要重新 backfill。 +- **decimal 欄位搭配 NUMERIC / DECIMAL**:原生先 CAST 成整數(`DECIMAL` 預設 (10,0)),扁平表精確比較,因此帶小數的值在區間邊界上可能不同。dev36 資料沒有這種值。 +- **text 欄位超過 255 字元**存 NULL,這類值的篩選結果會與原生不同(有 `text_overflow` 記錄)。 +- HivePress 搜尋 p50 為 321–866 ms(原生 600–1,713 ms),尚未達到 R-2.1 的 300 ms 目標(驗收:`notes/tmdo-acceptance-2.1-2026-09-26.md`)。價格篩選頁的 SQL 只剩約 75 ms、其餘為頁面渲染;純排序(如 `rating__desc`)必須 LEFT JOIN 保留沒有值的文章,無法 flat-first。 + +## 不在範圍內 + +- aeav_only 的空 key 讀取合併(B02):HP / WC 站不再使用 aeav_only,是否實作依其他站需求決定。 +- Hot zone 的移除(ADR-003 的後續步驟)。 diff --git a/includes/class-tmdo-cache-layer.php b/includes/class-tmdo-cache-layer.php index db21ce9..0db82cc 100644 --- a/includes/class-tmdo-cache-layer.php +++ b/includes/class-tmdo-cache-layer.php @@ -40,7 +40,7 @@ class TMDO_Cache_Layer { } $cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type ); - if ( empty( $cold_keys ) ) { + if ( empty( $cold_keys ) || ! self::cold_active( $post_type ) ) { return; } @@ -108,7 +108,7 @@ class TMDO_Cache_Layer { */ public static function warm_post( int $post_id, string $post_type ): void { $cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type ); - if ( empty( $cold_keys ) ) { + if ( empty( $cold_keys ) || ! self::cold_active( $post_type ) ) { return; } @@ -120,6 +120,19 @@ class TMDO_Cache_Layer { TMDO_Zone_Cold::get_blob( $post_id, $post_type ); } + /** + * Whether the cold zone holds data for this post type: its module writes + * to the cold table and the table exists. Registered cold fields alone do + * not mean either (zero side effect while the module is idle). + * + * @param string $post_type Post type. + * @return bool + */ + private static function cold_active( string $post_type ): bool { + return TMDO_Feature_Flags::is_write_active( TMDO_Zone_Router::module_name( 'cold', $post_type ) ) + && TMDO_Schema_Manager::table_exists( TMDO_Zone_Cold::table( $post_type ) ); + } + /** * Flush all cold cache for a post type. * diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index 505822b..7b0d4ea 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -44,6 +44,15 @@ final class TMDO_Hook_Bus { // ── 刪除攔截 ────────────────────────────────────────── add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 ); + // ── dual_write / shadow_read:原生寫入完成後,flat 從原生同步 ── + // Native is the source of truth in these modes; flat follows what + // native actually became (WP may skip or refuse a write). + add_action( "added_{$type}_meta", array( self::class, 'on_meta_added' ), 1, 4 ); + add_action( "updated_{$type}_meta", array( self::class, 'on_meta_updated' ), 1, 4 ); + add_action( "delete_{$type}_meta", array( self::class, 'before_meta_deleted' ), 1, 4 ); + add_action( "deleted_{$type}_meta", array( self::class, 'on_meta_deleted' ), 1, 4 ); + add_filter( "update_{$type}_metadata_by_mid", array( self::class, 'before_update_by_mid' ), 1, 4 ); + // ── 實體刪除時自動清理 ─────────────────────────────── add_action( $adapter->get_delete_hook(), @@ -70,13 +79,13 @@ final class TMDO_Hook_Bus { */ public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) { - // 已在短路中 → 避免遞迴 - if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) { + $type = self::resolve_type_from_current_filter(); + if ( ! $type ) { return $check; } - $type = self::resolve_type_from_current_filter(); - if ( ! $type ) { + // 已在短路中 → 避免遞迴 + if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) { return $check; } @@ -86,10 +95,10 @@ final class TMDO_Hook_Bus { } // ── Mode-aware dispatch ────────────────────────────── - // disabled : 完全放行給 WP 原生 meta(回 null / $check) - // dual_write: 寫 flat,然後 return null 讓 WP 繼續寫 EAV - // shadow_read: 寫 flat,然後 return null 讓 WP 繼續寫 EAV - // aeav_only : 寫 flat,return true 短路 WP(不寫 EAV) + // disabled : 完全放行給 WP 原生 meta(回 null / $check) + // dual_write : 放行 WP 寫 EAV;updated_/added_ action 再從原生同步 flat + // shadow_read: 同 dual_write + // aeav_only : 寫 flat,return true 短路 WP(不寫 EAV) if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) { return $check; // disabled } @@ -105,6 +114,13 @@ final class TMDO_Hook_Bus { return true; } + // dual_write / shadow_read: native first. Writing flat here, before WP + // decides whether native changes at all (same value, non-matching + // prev_value), let flat drift; sync_from_native() runs after the write. + if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) { + return null; + } + // 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。 // v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。 $before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' ); @@ -113,23 +129,18 @@ final class TMDO_Hook_Bus { do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value ); - // 若 mode 也要寫 EAV → return null 讓 WP 繼續 - if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) { - return null; // dual_write / shadow_read - } - return $flat_result; // aeav_only } public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) { - // 已在短路中 → 避免遞迴 - if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) { + $type = self::resolve_type_from_current_filter(); + if ( ! $type ) { return $check; } - $type = self::resolve_type_from_current_filter(); - if ( ! $type ) { + // 已在短路中 → 避免遞迴 + if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) { return $check; } @@ -150,6 +161,12 @@ final class TMDO_Hook_Bus { return true; } + // dual_write / shadow_read: native first (add($unique) may be refused); + // sync_from_native() runs on added_{type}_meta. + if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) { + return null; + } + // UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。 // 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。 $before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' ); @@ -158,11 +175,228 @@ final class TMDO_Hook_Bus { do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value ); - if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) { - return null; + return $flat_result; + } + + // ───────────────────────────────────────────────────────── + // dual_write / shadow_read:原生寫入後同步 flat + // ───────────────────────────────────────────────────────── + + /** + * Entity IDs whose native rows a pending delete removes, keyed "type:key". + * + * @var array + */ + private static array $pending_deletes = array(); + + /** + * Old keys of rows renamed through update_metadata_by_mid, keyed by meta ID. + * + * @var array + */ + private static array $pending_renames = array(); + + /** + * Copy the entity's current native value for a key into its flat column + * (NULL when native has no row). Runs in dual_write / shadow_read only, + * after WP wrote native, so flat always ends up equal to native — also + * when WP skipped or refused the write, and when an earlier direct SQL + * write had left flat stale. A NULL never creates a row: deleting an + * entity drops its flat row before WP deletes the meta rows one by one. + * + * @param string $type Entity type. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Value the caller wrote (for listeners). + * @param string $op 'add' | 'update' | 'delete'. + * @param bool $delete_all Whether the delete was a delete_all. + * @return void + */ + public static function sync_from_native( string $type, int $object_id, string $meta_key, $meta_value, string $op, bool $delete_all = false ): void { + if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) { + return; + } + $field_def = self::field_for( $type, $object_id, $meta_key ); + if ( ! $field_def || 'flat' !== self::decide_route( $op, $type, $object_id, $meta_key, $meta_value ) ) { + return; } - return $flat_result; + $before = self::maybe_read_before_value( $type, $object_id, $meta_key, $field_def, $op ); + $native = self::native_first_value( $type, $object_id, $meta_key ); + $result = null === $native + ? self::clear_column( $type, $object_id, $meta_key, $field_def ) + : self::perform_upsert( $type, $object_id, $meta_key, maybe_unserialize( $native ), $field_def ); + + if ( 'delete' === $op ) { + do_action( 'wpdo_after_delete', $type, $object_id, $meta_key, $meta_value, $result, $delete_all, $before ); + } else { + do_action( 'wpdo_after_write', $type, $object_id, $meta_key, $meta_value, $result, $op, $before ); + } + } + + /** + * `added_{type}_meta` action. + * + * @param int $meta_id Meta ID. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + */ + public static function on_meta_added( $meta_id, $object_id, $meta_key, $meta_value ): void { + $type = self::resolve_type_from_meta_hook(); + if ( $type ) { + self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'add' ); + } + } + + /** + * `updated_{type}_meta` action; also re-syncs the old key of a row renamed + * through update_metadata_by_mid. + * + * @param int $meta_id Meta ID. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + */ + public static function on_meta_updated( $meta_id, $object_id, $meta_key, $meta_value ): void { + $type = self::resolve_type_from_meta_hook(); + if ( ! $type ) { + return; + } + self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'update' ); + + $rename_key = $type . ':' . (int) $meta_id; + if ( isset( self::$pending_renames[ $rename_key ] ) ) { + $old = self::$pending_renames[ $rename_key ]; + unset( self::$pending_renames[ $rename_key ] ); + self::sync_from_native( $type, $old['id'], $old['key'], null, 'delete' ); + } + } + + /** + * `update_{type}_metadata_by_mid` filter: remember the old key when a row + * is renamed, so its flat column can follow. Never changes $check. + * + * @param mixed $check Short-circuit value. + * @param int $meta_id Meta ID. + * @param mixed $meta_value New value. + * @param string|false $meta_key New key, or false to keep it. + * @return mixed + */ + public static function before_update_by_mid( $check, $meta_id, $meta_value, $meta_key ) { + $type = self::resolve_type_from_meta_hook(); + if ( ! $type || false === $meta_key ) { + return $check; + } + $row = self::native_row_by_mid( $type, (int) $meta_id ); + if ( $row && $row['key'] !== $meta_key && TMDO_Entity_Registry::get_field( $type, $row['key'] ) ) { + self::$pending_renames[ $type . ':' . (int) $meta_id ] = array( + 'type' => $type, + 'id' => $row['id'], + 'key' => $row['key'], + ); + } + return $check; + } + + /** + * `delete_{type}_meta` action (rows still exist): record which entities a + * delete touches — a delete_all passes object ID 0. + * + * @param int[] $meta_ids Meta IDs to delete. + * @param int $object_id Entity ID (0 for delete_all). + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + */ + public static function before_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void { + $type = self::resolve_type_from_meta_hook(); + if ( ! $type || ! TMDO_Entity_Registry::get_field( $type, (string) $meta_key ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) { + return; + } + $ids = array(); + foreach ( (array) $meta_ids as $mid ) { + $row = self::native_row_by_mid( $type, (int) $mid ); + if ( $row ) { + $ids[ $row['id'] ] = $row['id']; + } + } + self::$pending_deletes[ $type . ':' . $meta_key ] = array_values( $ids ); + } + + /** + * `deleted_{type}_meta` action: re-sync every entity the delete touched. + * + * @param int[] $meta_ids Deleted meta IDs. + * @param int $object_id Entity ID (0 for delete_all). + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + */ + public static function on_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void { + $type = self::resolve_type_from_meta_hook(); + if ( ! $type ) { + return; + } + $pending_key = $type . ':' . $meta_key; + $ids = self::$pending_deletes[ $pending_key ] ?? array( (int) $object_id ); + unset( self::$pending_deletes[ $pending_key ] ); + + foreach ( $ids as $id ) { + if ( $id > 0 ) { + self::sync_from_native( $type, (int) $id, (string) $meta_key, $meta_value, 'delete', 0 === (int) $object_id ); + } + } + } + + /** + * First native value of a key (the one get_*_meta( $id, $key, true ) returns), + * read with SQL so no meta filter runs. Null when the entity has no row. + * + * @param string $type Entity type. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @return string|null + */ + private static function native_first_value( string $type, int $object_id, string $meta_key ): ?string { + global $wpdb; + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + if ( ! $adapter ) { + return null; + } + $table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + $pk = 'user' === $type ? 'umeta_id' : 'meta_id'; + // get_results(): get_var() turns an empty string into null. + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$pk}` LIMIT 1", $object_id, $meta_key ), + ARRAY_A + ); + return $rows ? (string) $rows[0]['meta_value'] : null; + } + + /** + * Entity ID and key of a native meta row. + * + * @param string $type Entity type. + * @param int $meta_id Meta ID. + * @return array{id:int, key:string}|null + */ + private static function native_row_by_mid( string $type, int $meta_id ): ?array { + global $wpdb; + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + if ( ! $adapter ) { + return null; + } + $table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + $pk = 'user' === $type ? 'umeta_id' : 'meta_id'; + $row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key AS mkey FROM `{$table}` WHERE `{$pk}` = %d", $meta_id ), ARRAY_A ); + if ( ! $row ) { + return null; + } + return array( + 'id' => (int) $row['id'], + 'key' => (string) $row['mkey'], + ); } /** @@ -291,6 +525,34 @@ final class TMDO_Hook_Bus { return 'flat'; } + /** + * Set a flat column to NULL on the entity's existing row; never insert one. + * + * @param string $type Entity type. + * @param int $entity_id Entity ID. + * @param string $meta_key Meta key. + * @param array $field_def Registered field. + * @return bool|null Null when there is no adapter or table. + */ + private static function clear_column( string $type, int $entity_id, string $meta_key, array $field_def ): ?bool { + global $wpdb; + + $adapter = TMDO_Entity_Registry::get_adapter( $type ); + $table = TMDO_Schema_Manager::get_table_name( $type, $field_def['group'] ); + if ( ! $adapter || ! TMDO_Schema_Manager::table_exists( $table ) ) { + return null; + } + + $col = TMDO_Schema_Manager::sanitize_column_name( $meta_key ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->update( $table, array( $col => null ), array( $adapter->get_entity_id_column() => $entity_id ) ); + + TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $field_def['group'] ); + self::touch_posts_last_changed( $type ); + + return false !== $result; + } + /** * 執行 Upsert 操作 */ @@ -322,8 +584,8 @@ final class TMDO_Hook_Bus { $db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def ); $format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] ); - // 鎖防遞迴 - $lock_key = $entity_id . ':' . $meta_key; + // 鎖防遞迴(含 entity type:user 5 與 post 5 不可互鎖) + $lock_key = $type . ':' . $entity_id . ':' . $meta_key; self::$internal_ops[ $lock_key ] = true; try { @@ -388,12 +650,20 @@ final class TMDO_Hook_Bus { // ── Mode-aware dispatch ────────────────────────────── // disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV // dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check - // shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff + // shadow_read : 讀取仍走 EAV,抽樣與 flat 比對記錄 diff // aeav_only : 讀取走 UAE flat,不讀 EAV if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) { return $check; // disabled / dual_write } + // shadow_read serves native: update_metadata() reads the old value + // through this filter, and serving the just-upserted flat value made + // WP see old === new and skip the native write (dev36, 2026-09-26). + if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) { + self::maybe_shadow_compare( $type, (int) $object_id, $meta_key, $field_def ); + return $check; + } + $group = $field_def['group']; $row = self::get_or_load_row( $type, (int) $object_id, $group ); @@ -401,22 +671,6 @@ final class TMDO_Hook_Bus { $has_value = is_array( $row ) && array_key_exists( $col, $row ); $value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null; - // Shadow-read:與 EAV 比對,記錄差異 - if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) { - try { - TMDO_Shadow_Diff_Logger::compare_and_log( - $type, - (int) $object_id, - $meta_key, - $value, - $field_def - ); - } catch ( \Throwable $e ) { - // 比對失敗不該影響讀取 - TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() ); - } - } - // 值為空 → 回 WP 原生慣例 if ( empty( $row ) || $value === null || $value === '' ) { return $single ? '' : array(); @@ -426,6 +680,36 @@ final class TMDO_Hook_Bus { return $single ? array( $value ) : array( $value ); } + /** + * shadow_read: compare the flat value with native meta for a sample of reads. + * + * Each compare costs a flat row read and a native SQL read, so only a share + * of reads is checked (filter `tmdo_shadow_compare_sample_rate`, 0..1, + * default 0.1). The logger rate-limits repeated diffs itself. + * + * @param string $type Entity type. + * @param int $object_id Entity ID. + * @param string $meta_key Meta key. + * @param array $field_def Field definition. + */ + private static function maybe_shadow_compare( string $type, int $object_id, string $meta_key, array $field_def ): void { + $rate = (float) apply_filters( 'tmdo_shadow_compare_sample_rate', 0.1, $type, $meta_key ); + if ( $rate <= 0 || ( $rate < 1 && wp_rand( 1, 1000 ) > $rate * 1000 ) ) { + return; + } + + $row = self::get_or_load_row( $type, $object_id, $field_def['group'] ); + $col = TMDO_Schema_Manager::sanitize_column_name( $meta_key ); + $value = array_key_exists( $col, $row ) ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null; + + try { + TMDO_Shadow_Diff_Logger::compare_and_log( $type, $object_id, $meta_key, $value, $field_def ); + } catch ( \Throwable $e ) { + // 比對失敗不該影響讀取 + TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() ); + } + } + /** * 取得(或載入)完整列資料,並快取 */ @@ -495,6 +779,12 @@ final class TMDO_Hook_Bus { return true; } + // dual_write / shadow_read: WP deletes native (also delete_all); + // deleted_{type}_meta re-syncs every touched entity from native. + if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) { + return null; + } + // 截取 before value(v1.3.1) $before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' ); @@ -579,12 +869,7 @@ final class TMDO_Hook_Bus { do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value ); - // 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta - if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) { - return null; - } - - return $result !== false; + return $result !== false; // aeav_only } // ───────────────────────────────────────────────────────── @@ -644,6 +929,17 @@ final class TMDO_Hook_Bus { return null; } + /** + * Entity type from the meta action / by-mid filter being run + * (added_user_meta, deleted_post_meta, update_term_metadata_by_mid, …). + */ + private static function resolve_type_from_meta_hook(): ?string { + if ( preg_match( '/^(?:(?:added|updated|delete|deleted)_(post|user|term|comment)_meta|update_(post|user|term|comment)_metadata_by_mid)$/', current_filter(), $matches ) ) { + return '' !== $matches[1] ? $matches[1] : $matches[2]; + } + return null; + } + /** * 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。 * diff --git a/includes/engine/class-tmdo-mode-manager.php b/includes/engine/class-tmdo-mode-manager.php index 5fed59d..fa79a90 100644 --- a/includes/engine/class-tmdo-mode-manager.php +++ b/includes/engine/class-tmdo-mode-manager.php @@ -140,6 +140,11 @@ final class TMDO_Mode_Manager { ); } + $guarded = TMDO_Promotion_Guard::check_promotion( $entity_type, $new_mode ); + if ( $guarded ) { + return $guarded; + } + $all = self::all(); $all[ $entity_type ] = $new_mode; diff --git a/includes/engine/class-tmdo-promotion-guard.php b/includes/engine/class-tmdo-promotion-guard.php new file mode 100644 index 0000000..4af8dac --- /dev/null +++ b/includes/engine/class-tmdo-promotion-guard.php @@ -0,0 +1,140 @@ + array( 'post', 'user', 'term', 'comment' ), + 'WooCommerce' => array( 'post', 'user' ), + ); + + /** + * Check a mode change. Only promotion to aeav_only is guarded. + * + * @param string $entity_type Entity type. + * @param string $new_mode Target mode. + * @return WP_Error|null Error when refused. + */ + public static function check_promotion( string $entity_type, string $new_mode ): ?WP_Error { + if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $new_mode ) { + return null; + } + return self::refuse( $entity_type, TMDO_Mode_Manager::MODE_AEAV_ONLY ); + } + + /** + * Check a cleanup of the entity's native managed meta rows. + * + * @param string $entity_type Entity type. + * @return WP_Error|null Error when refused. + */ + public static function check_cleanup( string $entity_type ): ?WP_Error { + return self::refuse( $entity_type, 'cleanup' ); + } + + /** + * Guarded entity types that are already in aeav_only (promoted before the + * guard existed). Native meta may be stale there. + * + * @return array Entity type → integration names. + */ + public static function violations(): array { + $out = array(); + foreach ( TMDO_Mode_Manager::all() as $type => $mode ) { + if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) { + continue; + } + $integrations = self::integrations_for( $type ); + if ( ! empty( $integrations ) ) { + $out[ $type ] = $integrations; + } + } + return $out; + } + + /** + * Active integrations that read this entity type natively. + * + * @param string $entity_type Entity type. + * @return string[] + */ + private static function integrations_for( string $entity_type ): array { + $active = array( + 'HivePress' => TMDO_Compatibility::is_hivepress_active(), + 'WooCommerce' => TMDO_Compatibility::is_woocommerce_active(), + ); + + $names = array(); + foreach ( self::GUARDED as $name => $types ) { + if ( $active[ $name ] && in_array( $entity_type, $types, true ) ) { + $names[] = $name; + } + } + return $names; + } + + /** + * Build the refusal, unless no integration applies or a filter allows it. + * + * @param string $entity_type Entity type. + * @param string $action 'aeav_only' or 'cleanup'. + * @return WP_Error|null + */ + private static function refuse( string $entity_type, string $action ): ?WP_Error { + $integrations = self::integrations_for( $entity_type ); + if ( empty( $integrations ) ) { + return null; + } + + /** + * Allow a guarded aeav_only promotion or cleanup anyway. + * + * @param bool $allow Default false. + * @param string $entity_type Entity type. + * @param string $action 'aeav_only' or 'cleanup'. + * @param string[] $integrations Integrations that read this entity natively. + */ + if ( apply_filters( 'tmdo_promotion_guard_allow', false, $entity_type, $action, $integrations ) ) { + return null; + } + + return new WP_Error( + 'promotion_guarded', + sprintf( + /* translators: 1: integration names, 2: entity type, 3: aeav_only or cleanup */ + __( '%1$s 直接讀取原生 meta,%2$s 不能執行 %3$s:這類站台以原生 meta 為唯一資料來源(搜尋索引模式)。', '2meet-data-optimizer' ), + implode( ' / ', $integrations ), + $entity_type, + $action + ) + ); + } +} diff --git a/includes/engine/class-tmdo-type-caster.php b/includes/engine/class-tmdo-type-caster.php index f6d8bbe..44c2bff 100644 --- a/includes/engine/class-tmdo-type-caster.php +++ b/includes/engine/class-tmdo-type-caster.php @@ -70,10 +70,12 @@ final class TMDO_Type_Caster { return $value; case 'text': - return (string) $value; + return self::fit_text( is_scalar( $value ) ? (string) $value : maybe_serialize( $value ), $field_def ); case 'textarea': - return (string) $value; + // Arrays / objects are serialized like WP stores them in native meta; + // (string) would store the literal "Array". + return is_scalar( $value ) ? (string) $value : maybe_serialize( $value ); default: // 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為) @@ -81,6 +83,33 @@ final class TMDO_Type_Caster { } } + /** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */ + private const TEXT_MAX_CHARS = 255; + + /** + * Keep a text value only if it fits its VARCHAR(255) column; otherwise + * store NULL (native meta keeps the full value) and log the overflow. + * + * @param string $value Value to store. + * @param array $field_def Field definition. + * @return string|null + */ + private static function fit_text( string $value, array $field_def ): ?string { + $length = mb_strlen( $value ); + if ( $length <= self::TEXT_MAX_CHARS ) { + return $value; + } + TMDO_Logger::warning( + 'text_overflow', + array( + 'group' => $field_def['group'] ?? '', + 'key' => $field_def['key'] ?? '', + 'length' => $length, + ) + ); + return null; + } + /** * DB → PHP 轉換(讀取) */ @@ -111,9 +140,12 @@ final class TMDO_Type_Caster { return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value; case 'enum': + return (string) $value; + case 'text': case 'textarea': - return (string) $value; + // Arrays were stored serialized (to_db); plain strings pass through. + return TMDO_Safe_Unserialize::run( (string) $value ); default: // v2.13.3: object-injection-safe unserialize (fixes L-DESER-1). diff --git a/includes/migration/class-tmdo-array-literal-repair.php b/includes/migration/class-tmdo-array-literal-repair.php new file mode 100644 index 0000000..a074020 --- /dev/null +++ b/includes/migration/class-tmdo-array-literal-repair.php @@ -0,0 +1,111 @@ +, native_polluted:array} + */ + public static function run( string $entity_type, bool $apply ): array { + global $wpdb; + + $report = array( + 'found' => 0, + 'repairable' => 0, + 'repaired' => 0, + 'unrepairable' => array(), + 'native_polluted' => array(), + ); + + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + if ( ! $adapter ) { + return $report; + } + $meta_table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + $meta_pk = 'user' === $entity_type ? 'umeta_id' : 'meta_id'; + + foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) { + $table = TMDO_Schema_Manager::get_table_name( $entity_type, $group ); + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + continue; + } + + foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) { + if ( ! in_array( $field['type'] ?? 'text', array( 'text', 'textarea' ), true ) ) { + continue; + } + $key = (string) $field['key']; + $col = TMDO_Schema_Manager::sanitize_column_name( $key ); + + $ids = $wpdb->get_col( $wpdb->prepare( "SELECT `{$id_col}` FROM `{$table}` WHERE `{$col}` = %s", self::LITERAL ) ); + foreach ( $ids as $id ) { + $id = (int) $id; + ++$report['found']; + + $native = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$meta_table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$meta_pk}` LIMIT 1", $id, $key ) ); + $reason = null === $native ? 'no_native_row' : ( self::LITERAL === $native ? 'native_is_array_literal' : '' ); + if ( '' !== $reason ) { + $report['unrepairable'][] = array( + 'group' => $group, + 'key' => $key, + 'id' => $id, + 'reason' => $reason, + ); + continue; + } + + ++$report['repairable']; + if ( $apply ) { + $value = TMDO_Type_Caster::to_db( TMDO_Safe_Unserialize::run( $native ), $field + array( 'group' => $group ) ); + $wpdb->update( $table, array( $col => $value ), array( $id_col => $id ) ); + ++$report['repaired']; + } + } + + $polluted = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT `{$id_col}` FROM `{$meta_table}` WHERE meta_key = %s AND meta_value = %s", $key, self::LITERAL ) ); + foreach ( $polluted as $id ) { + $report['native_polluted'][] = array( + 'group' => $group, + 'key' => $key, + 'id' => (int) $id, + ); + } + } + } + + if ( $report['repaired'] > 0 ) { + TMDO_Cache_Orchestrator::flush_entity( $entity_type ); + } + + return $report; + } +} diff --git a/includes/migration/class-tmdo-native-restorer.php b/includes/migration/class-tmdo-native-restorer.php index 0fa3cf8..15f9bbd 100644 --- a/includes/migration/class-tmdo-native-restorer.php +++ b/includes/migration/class-tmdo-native-restorer.php @@ -14,6 +14,16 @@ * the value Type_Caster returns); a NULL value deletes the native row. * - Entities with no row in the owning group's table are not touched: * leftover native rows for them are outside the flat data. + * - A flat value equal to its field default is not written for an entity + * that has no native row (e.g. activity.session_count = 0): that would + * invent native meta the entity never had. + * + * Policy (which side wins where both hold a value): + * - overwrite — flat wins; the default in aeav_only, where writes only + * reached the flat tables and native is stale. + * - only_missing — native wins, flat only fills gaps; the default in every + * other mode, where native is the source of truth (and may + * be newer, e.g. WooCommerce stock written with direct SQL). * * Batched by entity id with a cursor option so an interrupted run resumes; a * final pass re-copies rows updated since the first run started. With @@ -52,14 +62,19 @@ final class TMDO_Native_Restorer { * @param int $batch Rows per batch. * @param bool $demote After a complete run, switch the entity to dual_write. * @param string $group Only this group ('' = every group). - * @return array{groups: array, demoted: bool} + * @param string $policy 'overwrite' | 'only_missing' | '' (by the entity's mode). + * @return array{groups: array, demoted: bool, policy: string} */ - public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '' ): array { + public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '', string $policy = '' ): array { global $wpdb; + if ( ! in_array( $policy, array( 'overwrite', 'only_missing' ), true ) ) { + $policy = TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ? 'overwrite' : 'only_missing'; + } $report = array( 'groups' => array(), 'demoted' => false, + 'policy' => $policy, ); $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); if ( ! $adapter ) { @@ -83,8 +98,12 @@ final class TMDO_Native_Restorer { } foreach ( $tables as $group => $table ) { $entry = array( - 'rows' => 0, - 'written' => 0, + 'rows' => 0, + 'written' => 0, + 'missing' => 0, + 'differs' => 0, + 'same' => 0, + 'default_skipped' => 0, ); $key = "{$entity_type}/{$group}"; $last = $dry_run ? 0 : (int) ( $cursor[ $key ] ?? 0 ); @@ -94,9 +113,11 @@ final class TMDO_Native_Restorer { $entry['rows'] += $fetched; if ( $fetched ) { $last = (int) end( $rows )[ $id_col ]; + foreach ( self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, ! $dry_run ) as $count_key => $n ) { + $entry[ $count_key ] += $n; + } if ( ! $dry_run ) { - $entry['written'] += self::write_rows( $entity_type, $group, $adapter, $rows ); - $cursor[ $key ] = $last; + $cursor[ $key ] = $last; update_option( self::CURSOR_OPTION, $cursor, false ); } } @@ -112,7 +133,7 @@ final class TMDO_Native_Restorer { // Catch-up: writes that landed in the flat tables after a batch passed their row. foreach ( $tables as $group => $table ) { $rows = self::fetch( $entity_type, $table, $id_col, 't.updated_at >= %s', array( $started[ $entity_type ] ) ); - self::write_rows( $entity_type, $group, $adapter, $rows ); + self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, true ); } foreach ( array_keys( $tables ) as $group ) { @@ -235,24 +256,87 @@ final class TMDO_Native_Restorer { } /** - * Replace the native meta of the keys this group owns for each row. + * Compare the native meta of the keys this group owns with the flat rows, + * and (when $write) bring native in line according to $policy. * * @param string $entity_type Entity type. * @param string $group Group the rows come from. * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. * @param array> $rows Flat rows. - * @return int Native values written. + * @param string $policy 'overwrite' | 'only_missing'. + * @param bool $write False = classify only (dry run). + * @return array{written:int, missing:int, differs:int, same:int, default_skipped:int} */ - private static function write_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): int { - global $wpdb; - if ( empty( $rows ) ) { - return 0; + private static function apply_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows, string $policy, bool $write ): array { + $counts = array( + 'written' => 0, + 'missing' => 0, + 'differs' => 0, + 'same' => 0, + 'default_skipped' => 0, + ); + list( $wanted, $fields ) = self::flat_values( $entity_type, $group, $adapter, $rows ); + if ( empty( $wanted ) ) { + return $counts; } - $meta_table = $adapter->get_native_meta_table(); - $id_col = $adapter->get_entity_id_column(); + $native = self::native_values( $entity_type, $adapter, array_keys( $wanted ), array_keys( $fields ) ); $deletes = array(); $inserts = array(); + foreach ( $wanted as $id => $values ) { + foreach ( $values as $key => $value ) { + $has = isset( $native[ $id ] ) && array_key_exists( $key, $native[ $id ] ); + if ( null === $value ) { + if ( $has ) { + ++$counts['differs']; + if ( 'overwrite' === $policy ) { + $deletes[ $key ][] = $id; + } + } + continue; + } + if ( ! $has ) { + if ( self::is_default( $value, $fields[ $key ] ) ) { + ++$counts['default_skipped']; + continue; + } + ++$counts['missing']; + $inserts[] = array( $id, $key, $value ); + continue; + } + if ( self::same_value( $native[ $id ][ $key ], $value ) ) { + ++$counts['same']; + continue; + } + ++$counts['differs']; + if ( 'overwrite' === $policy ) { + $deletes[ $key ][] = $id; + $inserts[] = array( $id, $key, $value ); + } + } + } + + if ( $write ) { + self::write_native( $entity_type, $adapter, $deletes, $inserts, array_keys( $wanted ) ); + $counts['written'] = count( $inserts ); + } + return $counts; + } + + /** + * Values this group would write to native, per entity and key (NULL = none), + * restricted to the keys the group owns for that entity. + * + * @param string $entity_type Entity type. + * @param string $group Group name. + * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. + * @param array> $rows Flat rows. + * @return array{0: array>, 1: array>} + */ + private static function flat_values( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): array { + $id_col = $adapter->get_entity_id_column(); + $wanted = array(); + $fields = array(); foreach ( $rows as $row ) { $id = (int) $row[ $id_col ]; $post_type = 'post' === $entity_type ? (string) ( $row['__post_type'] ?? '' ) : ''; @@ -262,25 +346,93 @@ final class TMDO_Native_Restorer { if ( null === $owner || $owner['group'] !== $group || ! array_key_exists( $col, $row ) ) { continue; } - $deletes[ $field['key'] ][] = $id; - if ( null !== $row[ $col ] ) { - $inserts[] = array( $id, $field['key'], (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) ) ); - } + $fields[ $field['key'] ] = $field; + $wanted[ $id ][ $field['key'] ] = null === $row[ $col ] + ? null + : (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) ); } } + return array( $wanted, $fields ); + } - foreach ( $deletes as $meta_key => $ids ) { - $in = implode( ',', array_map( 'intval', $ids ) ); + /** + * First native value per entity and key. + * + * @param string $entity_type Entity type. + * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. + * @param int[] $ids Entity IDs. + * @param string[] $keys Meta keys. + * @return array> + */ + private static function native_values( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $ids, array $keys ): array { + global $wpdb; + $meta_table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + $pk = 'user' === $entity_type ? 'umeta_id' : 'meta_id'; + $in_ids = implode( ',', array_map( 'intval', $ids ) ); + $in_keys = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); + $rows = (array) $wpdb->get_results( + $wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key, meta_value FROM `{$meta_table}` WHERE `{$id_col}` IN ({$in_ids}) AND meta_key IN ({$in_keys}) ORDER BY `{$pk}`", ...$keys ), + ARRAY_A + ); + + $native = array(); + foreach ( $rows as $r ) { + if ( ! isset( $native[ (int) $r['id'] ][ $r['meta_key'] ] ) ) { + $native[ (int) $r['id'] ][ $r['meta_key'] ] = (string) $r['meta_value']; + } + } + return $native; + } + + /** + * Delete / insert native rows and clear the entities' meta caches. + * + * @param string $entity_type Entity type. + * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. + * @param array $deletes Meta key → entity IDs to clear first. + * @param array $inserts Rows to insert. + * @param int[] $ids Entities touched. + * @return void + */ + private static function write_native( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $deletes, array $inserts, array $ids ): void { + global $wpdb; + $meta_table = $adapter->get_native_meta_table(); + $id_col = $adapter->get_entity_id_column(); + + foreach ( $deletes as $meta_key => $del_ids ) { + $in = implode( ',', array_map( 'intval', $del_ids ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM `{$meta_table}` WHERE meta_key = %s AND `{$id_col}` IN ({$in})", $meta_key ) ); } if ( $inserts ) { $values = implode( ', ', array_fill( 0, count( $inserts ), '(%d, %s, %s)' ) ); $wpdb->query( $wpdb->prepare( "INSERT INTO `{$meta_table}` (`{$id_col}`, meta_key, meta_value) VALUES {$values}", ...array_merge( ...$inserts ) ) ); } - - foreach ( array_unique( array_map( static fn( $row ) => (int) $row[ $id_col ], $rows ) ) as $id ) { + foreach ( $ids as $id ) { wp_cache_delete( $id, "{$entity_type}_meta" ); } - return count( $inserts ); + } + + /** + * Whether a stored value (as WP would store it) equals the field default. + * + * @param string $value Value as it would be stored. + * @param array $field Field definition. + * @return bool + */ + private static function is_default( string $value, array $field ): bool { + return isset( $field['default'] ) && (string) maybe_serialize( $field['default'] ) === $value; + } + + /** + * Native and candidate values are equal (numeric-aware: '3' equals '3.000000'). + * + * @param string $native Native meta value. + * @param string $candidate Value from the flat table. + * @return bool + */ + private static function same_value( string $native, string $candidate ): bool { + return $native === $candidate + || ( is_numeric( $native ) && is_numeric( $candidate ) && (float) $native === (float) $candidate ); } } diff --git a/includes/migration/class-tmdo-post-migration.php b/includes/migration/class-tmdo-post-migration.php index 67b54f8..26caf95 100644 --- a/includes/migration/class-tmdo-post-migration.php +++ b/includes/migration/class-tmdo-post-migration.php @@ -228,6 +228,10 @@ final class TMDO_Post_Migration { 'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only' ); } + $guarded = TMDO_Promotion_Guard::check_cleanup( self::ENTITY_TYPE ); + if ( $guarded ) { + throw new RuntimeException( esc_html( $guarded->get_error_message() ) ); + } global $wpdb; $keys = self::get_managed_keys(); diff --git a/includes/migration/phases/class-tmdo-phase-cleanup.php b/includes/migration/phases/class-tmdo-phase-cleanup.php index 28b59e1..2c2a0a6 100644 --- a/includes/migration/phases/class-tmdo-phase-cleanup.php +++ b/includes/migration/phases/class-tmdo-phase-cleanup.php @@ -46,6 +46,10 @@ class TMDO_Phase_Cleanup extends TMDO_Migration_Phase_Base { if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) { throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" ); } + $guarded = TMDO_Promotion_Guard::check_cleanup( $this->entity_type ); + if ( $guarded ) { + throw new \RuntimeException( $guarded->get_error_message() ); + } if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) { throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' ); } diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index c2f1074..bf1f1ac 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -16,16 +16,18 @@ * Hook order: * 1. pre_get_posts — strip registered keys from meta_query, store in * $query->set( 'wpdo_post_clauses', $routed ) - * 2. posts_join — LEFT JOIN wp_wpdo_post_ per routed post_type + * 2. posts_join — LEFT JOIN wp_wpdo_post_ per routed group * 3. posts_where — append WHERE conditions targeting flat columns - * 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication + * + * Flat tables hold one row per post (UNIQUE post_id), so the joins never + * multiply rows and no GROUP BY is added. * * Status / date on the flat table (O-1): when the group's post_status / * post_date copies are ready (TMDO_Post_Status_Sync) and none is missing, * a single-post-type search with explicit statuses and date ordering - * INNER JOINs the table, filters the copies, orders by the copied - * post_date and skips GROUP BY (one row per post), so MySQL can walk - * `idx_post_status_date` and stop at the LIMIT. + * INNER JOINs the table, filters the copies and orders by the copied + * post_date, so MySQL can walk `idx_post_status_date` and stop at the + * LIMIT. Not with NOT EXISTS: posts without a flat row must still match. * * Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every * match inside the page query); found_posts_query answers from a COUNT of the @@ -58,12 +60,18 @@ class TMDO_Post_Query_Router { /** Query var holding the status / date copy route, when one applies. */ private const COPY_VAR = 'wpdo_post_copy_route'; + /** Query var holding the flat column a routed meta orderby sorts on. */ + private const SORT_VAR = 'wpdo_post_sort'; + /** Query var holding the COUNT for found_posts_query. */ private const COUNT_VAR = 'wpdo_post_count_sql'; /** Object cache group for found-rows counts. */ private const COUNT_CACHE_GROUP = 'tmdo_found_rows'; + /** Comparison operators the router can express on a flat column. */ + private const COMPARES = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ); + /** * Per-request answer to "are this group's copies usable?". * @@ -77,12 +85,15 @@ class TMDO_Post_Query_Router { * @return void */ public function register_hooks(): void { - add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 ); + // Late, so meta clauses other plugins add at the default priority + // (e.g. HivePress search filters and sorting) are already in the query. + add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 1000, 1 ); add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 ); add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 ); - add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 ); add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 ); add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 ); + add_filter( 'split_the_query', array( $this, 'split_the_query' ), 10, 4 ); + add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 ); add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 ); } @@ -103,60 +114,104 @@ class TMDO_Post_Query_Router { return; } + // One post type only: with several, a clause may belong to one type's + // group and the others must still match through wp_postmeta. $post_types = (array) $query->get( 'post_type' ); - if ( empty( $post_types ) ) { + if ( 1 !== count( $post_types ) ) { return; } + $pt = (string) reset( $post_types ); $raw_meta_query = (array) $query->get( 'meta_query' ); - if ( empty( $raw_meta_query ) ) { - return; - } + // All or nothing: routed clauses are ANDed into the WHERE, so the + // query is routed only when every registered clause can be, and + // the remaining (unregistered) clauses are ANDed too. + $relation = strtoupper( (string) ( $raw_meta_query['relation'] ?? 'AND' ) ); + $orderby = $query->get( 'orderby' ); + $named = is_array( $orderby ) ? array_keys( $orderby ) : preg_split( '/[\s,]+/', trim( (string) $orderby ) ); + $sort_name = 1 === count( $named ) ? (string) reset( $named ) : ''; + $direction = 'ASC' === strtoupper( (string) ( is_array( $orderby ) ? reset( $orderby ) : $query->get( 'order' ) ) ) ? 'ASC' : 'DESC'; $routed = array(); // Keyed by post_type. $remaining = array(); + $sort = array(); + + // meta_key + orderby meta_value(_num): native INNER JOINs the key + // (EXISTS) and orders by meta_value+0 (NUMERIC) or meta_value. + $meta_key = $query->get( 'meta_key' ); + $take_key = is_string( $meta_key ) && in_array( $sort_name, array( 'meta_value', 'meta_value_num' ), true ) + && null !== self::field_for( array( 'key' => $meta_key ), $pt ); + if ( $take_key ) { + $field = self::field_for( array( 'key' => $meta_key ), $pt ); + $type = 'meta_value_num' === $sort_name ? 'NUMERIC' : (string) $query->get( 'meta_type' ); + $route = self::route_clause( + array( + 'key' => $meta_key, + 'compare' => 'EXISTS', + 'type' => $type, + ), + $field + ); + if ( ! $route || ! self::sortable( $route, $field ) || '' !== $query->get( 'meta_value' ) || '' !== $query->get( 'meta_compare' ) ) { + return; + } + $routed[ $pt ][] = $route; + $sort = self::sort_for( $pt, $route, $direction ); + } foreach ( $raw_meta_query as $k => $clause ) { - if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { + if ( 'relation' === $k || ! is_array( $clause ) ) { + $remaining[ $k ] = $clause; + continue; + } + if ( ! isset( $clause['key'] ) ) { + // Sort shape: matches every post, only orders by the key. + $name = 'AND' === $relation ? self::sort_shape( $clause ) : ''; + $field = '' !== $name && $name === $sort_name ? self::field_for( $clause[ $name ], $pt ) : null; + if ( $field ) { + $route = self::route_clause( array( 'compare' => 'EXISTS' ) + $clause[ $name ], $field ); + if ( ! $route || ! self::sortable( $route, $field ) ) { + return; + } + $sort = self::sort_for( $pt, $route, $direction ); + continue; + } + if ( self::has_registered_key( $clause, $pt ) ) { + return; // Nested group. + } $remaining[ $k ] = $clause; continue; } - $matched = false; - foreach ( $post_types as $pt ) { - $field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], (string) $pt ); - if ( ! $field ) { - continue; - } - // Confirm the field's group is appropriate for this post_type: - // either cross-cutting wp_core (any) or the post_type-specific group. - $group = $field['group'] ?? ''; - $is_appropriate = ( 'wp_core' === $group ) || in_array( $group, TMDO_Post_Fields::groups_for_post_type( (string) $pt ), true ); - if ( ! $is_appropriate ) { - continue; - } - - $column = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ); - - $routed[ $pt ][] = array( - 'group' => $group, - 'column' => $column, - 'value' => $clause['value'] ?? '', - 'compare' => strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) ), - 'type' => strtoupper( trim( (string) ( $clause['type'] ?? 'CHAR' ) ) ), - ); - $matched = true; - break; - } - - if ( ! $matched ) { + $field = self::field_for( $clause, $pt ); + if ( ! $field ) { $remaining[ $k ] = $clause; + continue; } + $route = self::route_clause( $clause, $field ); + if ( ! $route || 'AND' !== $relation ) { + return; + } + // Named by orderby: only as the sole ordering, on a column that + // orders like native (otherwise the name would be lost). + if ( is_string( $k ) && in_array( $k, $named, true ) ) { + if ( $k !== $sort_name || ! self::sortable( $route, $field ) ) { + return; + } + $sort = self::sort_for( $pt, $route, $direction ); + } + $routed[ $pt ][] = $route; } - if ( empty( $routed ) ) { + if ( empty( $routed ) && empty( $sort ) ) { return; } + if ( $take_key ) { + $query->set( 'meta_key', '' ); + } + if ( $sort ) { + $query->set( self::SORT_VAR, $sort ); + } // Preserve relation if surviving clauses still have it. if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) { @@ -173,7 +228,7 @@ class TMDO_Post_Query_Router { } /** - * LEFT JOIN flat tables for each routed post_type. + * LEFT JOIN the flat table of each routed group. * * @param string|null $join Current JOIN SQL. * @param \WP_Query $query Current WP_Query. @@ -182,19 +237,20 @@ class TMDO_Post_Query_Router { public function posts_join( ?string $join, \WP_Query $query ): string { $join = (string) ( $join ?? '' ); $routed = $query->get( self::QUERY_VAR ); - if ( empty( $routed ) || ! is_array( $routed ) ) { - return $join; + $sort = $query->get( self::SORT_VAR ); + $pairs = array(); // Pairs of post type and group. + foreach ( is_array( $routed ) ? $routed : array() as $post_type => $clauses ) { + foreach ( array_column( $clauses, 'group' ) as $group ) { + $pairs[] = array( $post_type, $group ); + } + } + if ( ! empty( $sort ) ) { + $pairs[] = array( $sort['post_type'], $sort['group'] ); } global $wpdb; - foreach ( $routed as $post_type => $clauses ) { - // Each routed clause carries its group; use the first clause's group - // (all clauses for a given post_type currently target one group). - $group = $clauses[0]['group'] ?? ''; - if ( '' === $group ) { - continue; - } + foreach ( $pairs as list( $post_type, $group ) ) { $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group ); $alias = self::table_alias( $post_type, $group ); @@ -203,6 +259,7 @@ class TMDO_Post_Query_Router { continue; } + // The copy route only applies to single-group searches. $type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT'; $join .= " {$type} JOIN `{$table}` AS `{$alias}` " . "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)"; @@ -248,7 +305,8 @@ class TMDO_Post_Query_Router { } /** - * Order by the flat table's post_date copy when the copy route applies. + * Order by the routed meta orderby column (ties by ID), or by the flat + * table's post_date copy when the copy route applies. * * @param string|null $orderby Current ORDER BY SQL. * @param \WP_Query $query Current WP_Query. @@ -256,35 +314,19 @@ class TMDO_Post_Query_Router { */ public function posts_orderby( ?string $orderby, \WP_Query $query ): string { $orderby = (string) ( $orderby ?? '' ); - $copy = $query->get( self::COPY_VAR ); + $sort = $query->get( self::SORT_VAR ); + if ( ! empty( $sort ) ) { + global $wpdb; + return '`' . self::table_alias( $sort['post_type'], $sort['group'] ) . '`.`' . esc_sql( $sort['column'] ) . '` ' + . $sort['order'] . ", `{$wpdb->posts}`.`ID` DESC"; + } + $copy = $query->get( self::COPY_VAR ); if ( empty( $copy ) ) { return $orderby; } return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order']; } - /** - * Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows. - * - * @param string|null $groupby Current GROUP BY SQL. - * @param \WP_Query $query Current WP_Query. - * @return string Modified GROUP BY SQL. - */ - public function posts_groupby( ?string $groupby, \WP_Query $query ): string { - $groupby = (string) ( $groupby ?? '' ); - $routed = $query->get( self::QUERY_VAR ); - if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) { - return $groupby; - } - - global $wpdb; - if ( '' === trim( $groupby ) ) { - $groupby = "`{$wpdb->posts}`.`ID`"; - } - - return $groupby; - } - /** * Drop SQL_CALC_FOUND_ROWS from a routed search; keep its COUNT for later. * @@ -294,15 +336,45 @@ class TMDO_Post_Query_Router { */ public function posts_request( ?string $request, \WP_Query $query ): string { $request = (string) ( $request ?? '' ); - if ( empty( $query->get( self::QUERY_VAR ) ) || false === strpos( $request, 'SQL_CALC_FOUND_ROWS' ) ) { + $routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) ); + if ( ! $routed ) { + return $request; + } + $counted = false !== strpos( $request, 'SQL_CALC_FOUND_ROWS' ); + $request = self::routed_request( $request, $query ); + if ( ! $counted ) { return $request; } - $request = preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 ); $matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request ); $query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" ); return $request; } + /** + * Keep WordPress's split query (IDs first, then cached post objects) on a + * routed search: posts_request only dropped SQL_CALC_FOUND_ROWS, yet + * WordPress takes any change for a filtered request and selects full rows + * (a temporary table sorting whole posts). The split ID request passes + * through posts_request_ids, which drops SQL_CALC_FOUND_ROWS again. + * + * @param bool $split Core decision. + * @param \WP_Query $query Current WP_Query. + * @param string $old_request Request before posts_request. + * @param array $clauses Query clauses (fields, limits …). + * @return bool + */ + public function split_the_query( $split, \WP_Query $query, $old_request = '', $clauses = array() ): bool { + $routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) ); + if ( $split || ! $routed ) { + return (bool) $split; + } + global $wpdb; + // Core's other conditions: full rows, and a LIMIT under 500 or an external object cache. + return "{$wpdb->posts}.*" === ( $clauses['fields'] ?? '' ) + && self::routed_request( (string) $old_request, $query ) === $query->request + && ( ( '' !== ( $clauses['limits'] ?? '' ) && (int) $query->get( 'posts_per_page' ) < 500 ) || wp_using_ext_object_cache() ); + } + /** * Answer found rows from the cached COUNT of a routed search. * @@ -363,7 +435,8 @@ class TMDO_Post_Query_Router { } $post_type = (string) array_key_first( $routed ); $groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) ); - if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true ) ) { + if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true ) + || in_array( 'NOT EXISTS', array_column( $routed[ $post_type ], 'compare' ), true ) ) { return array(); } $statuses = $query->get( 'post_status' ); @@ -411,6 +484,254 @@ class TMDO_Post_Query_Router { return self::$copies_usable[ $group ]; } + /** + * The registered field a clause targets for this post type, or null. + * + * @param array $clause Meta query clause. + * @param string $post_type Post type slug. + * @return array|null + */ + private static function field_for( array $clause, string $post_type ): ?array { + if ( ! is_string( $clause['key'] ?? null ) || ! in_array( $clause['compare_key'] ?? '=', array( '=', '' ), true ) ) { + return null; + } + $field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], $post_type ); + if ( ! $field ) { + return null; + } + // Either cross-cutting wp_core or one of the post type's own groups. + $group = $field['group'] ?? ''; + return ( 'wp_core' === $group || in_array( $group, TMDO_Post_Fields::groups_for_post_type( $post_type ), true ) ) ? $field : null; + } + + /** + * Whether a nested meta query group references a registered key. + * + * @param array $group Nested group. + * @param string $post_type Post type slug. + * @return bool + */ + private static function has_registered_key( array $group, string $post_type ): bool { + foreach ( $group as $clause ) { + if ( ! is_array( $clause ) ) { + continue; + } + if ( isset( $clause['key'] ) ? null !== self::field_for( $clause, $post_type ) : self::has_registered_key( $clause, $post_type ) ) { + return true; + } + } + return false; + } + + /** + * The routed form of a clause, or null when the flat column would not + * compare the way WordPress compares the meta_value string. + * + * Native casts meta_value by the clause type (CHAR when absent); flat + * columns are typed. Routed: EXISTS / NOT EXISTS; string columns with + * CHAR; numeric columns with a numeric type (no LIKE); integer columns + * with CHAR =, !=, IN, NOT IN on plain integers ("10" = "10" either way). + * + * @param array $clause Meta query clause. + * @param array $field Registered field. + * @return array{group:string, column:string, value:mixed, compare:string, type:string}|null + */ + private static function route_clause( array $clause, array $field ): ?array { + $compare = strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) ); + $type = strtoupper( trim( (string) ( $clause['type'] ?? '' ) ) ); + $type = '' === $type ? 'CHAR' : $type; + $value = $clause['value'] ?? ''; + $kind = $field['type'] ?? 'text'; + $multi = in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ); + + if ( ! in_array( $compare, self::COMPARES, true ) || ( ! $multi && is_array( $value ) ) ) { + return null; + } + + $numeric_type = self::is_numeric_type( $type ); + if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) { + $ok = in_array( $kind, array( 'text', 'textarea', 'enum', 'integer', 'decimal', 'boolean' ), true ); + } elseif ( in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) ) { + $ok = 'CHAR' === $type; + } elseif ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) && $numeric_type ) { + $ok = ! in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true ); + } else { + $ok = in_array( $kind, array( 'integer', 'boolean' ), true ) && 'CHAR' === $type + && in_array( $compare, array( '=', '!=', '<>', 'IN', 'NOT IN' ), true ) + && array() === array_filter( (array) $value, static fn( $v ) => ! is_scalar( $v ) || (string) (int) $v !== (string) $v ); + } + if ( ! $ok ) { + return null; + } + + return array( + 'group' => (string) $field['group'], + 'column' => TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ), + 'value' => $value, + 'compare' => $compare, + 'type' => $type, + ); + } + + /** + * What posts_request makes of a routed request. + * + * @param string $request SELECT statement. + * @param \WP_Query $query Current WP_Query. + * @return string + */ + private static function routed_request( string $request, \WP_Query $query ): string { + return self::flat_first( self::without_found_rows( $request ), $query ); + } + + /** + * Drive a filtered search ordered by a flat column from its flat table. + * + * MariaDB starts from wp_posts (the type / status index), looks every + * candidate up in the flat table and sorts in a temporary table. From the + * flat table, with the posts.ID tie-break read as the equal flat post_id, + * it sorts one table (dev36, 100k listings: meta_value_num 263 → 147 ms, + * HivePress sort + filter 300 → 36 ms). Only when the routed conditions + * already require a flat row (LEFT JOIN = INNER JOIN), all on the sort + * group, without a selective post filter; `tmdo_router_flat_first` can + * turn it off. + * + * @param string $request SELECT statement. + * @param \WP_Query $query Current WP_Query. + * @return string + */ + private static function flat_first( string $request, \WP_Query $query ): string { + $routed = $query->get( self::QUERY_VAR ); + $sort = $query->get( self::SORT_VAR ); + if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) { + return $request; + } + $post_type = (string) array_key_first( $routed ); + $groups = array_unique( array_column( $routed[ $post_type ], 'group' ) ); + $compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) ); + if ( empty( $sort ) || 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) { + return $request; + } + foreach ( array( 'p', 'page_id', 'name', 'pagename', 'attachment_id', 'post__in', 'post_parent', 'post_parent__in', 'author', 'author__in', 'author_name' ) as $var ) { + if ( ! empty( $query->get( $var ) ) ) { + return $request; + } + } + if ( ! apply_filters( 'tmdo_router_flat_first', true, $query ) ) { + return $request; + } + + global $wpdb; + $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $groups[0] ); + $alias = self::table_alias( $post_type, $groups[0] ); + $on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)"; + $join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}"; + if ( 1 !== substr_count( $request, $join ) ) { + return $request; + } + $flat_first = preg_replace( + '/\bFROM\s+' . preg_quote( $wpdb->posts, '/' ) . '\b/', + "FROM `{$table}` AS `{$alias}` INNER JOIN {$wpdb->posts} ON {$on}", + str_replace( $join, '', $request ), + 1, + $replaced + ); + if ( 1 !== $replaced ) { + return $request; + } + // Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id. + $flat_first = preg_replace_callback( + '/\bORDER BY\b(?!.*\bORDER BY\b).*$/s', + static fn( $m ) => preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)/', "`{$alias}`.`post_id`", $m[0] ), + (string) $flat_first + ); + return (string) preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', (string) $flat_first, 1 ); + } + + /** + * A request without its SQL_CALC_FOUND_ROWS. + * + * @param string $request SELECT statement. + * @return string + */ + private static function without_found_rows( string $request ): string { + return (string) preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 ); + } + + /** + * Whether a meta query type casts to a number (NUMERIC, SIGNED, DECIMAL(p,s) …). + * + * @param string $type Upper-cased clause type. + * @return bool + */ + private static function is_numeric_type( string $type ): bool { + return 1 === preg_match( '/^(NUMERIC|SIGNED|UNSIGNED|DECIMAL)(\(\d+(,\s?\d+)?\))?$/', $type ); + } + + /** + * Whether ordering by the flat column matches native ordering by + * CAST(meta_value AS type): numbers by a numeric type, strings by CHAR. + * + * @param array{type:string} $route Routed clause. + * @param array $field Registered field. + * @return bool + */ + private static function sortable( array $route, array $field ): bool { + $kind = $field['type'] ?? 'text'; + if ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) ) { + return self::is_numeric_type( $route['type'] ); + } + return in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) && 'CHAR' === $route['type']; + } + + /** + * The ordering a routed meta orderby resolves to. + * + * @param string $post_type Post type slug. + * @param array{group:string, column:string} $route Routed clause. + * @param string $direction ASC or DESC. + * @return array{post_type:string, group:string, column:string, order:string} + */ + private static function sort_for( string $post_type, array $route, string $direction ): array { + return array( + 'post_type' => $post_type, + 'group' => $route['group'], + 'column' => $route['column'], + 'order' => $direction, + ); + } + + /** + * The clause name of a "sort by a key, keep posts without it" group — + * relation OR of exactly NOT EXISTS and EXISTS on one key and type, one + * of them named (HivePress search sorting) — or '' for any other group. + * + * @param array $group Nested meta query group. + * @return string + */ + private static function sort_shape( array $group ): string { + if ( 'OR' !== strtoupper( (string) ( $group['relation'] ?? '' ) ) ) { + return ''; + } + unset( $group['relation'] ); + if ( 2 !== count( $group ) ) { + return ''; + } + $name = ''; + $compares = array(); + foreach ( $group as $k => $clause ) { + if ( ! is_array( $clause ) || ! is_string( $clause['key'] ?? null ) ) { + return ''; + } + $compares[] = strtoupper( (string) ( $clause['compare'] ?? '=' ) ); + $name = is_string( $k ) ? $k : $name; + } + list( $a, $b ) = array_values( $group ); + sort( $compares ); + $same = $a['key'] === $b['key'] && strtoupper( (string) ( $a['type'] ?? '' ) ) === strtoupper( (string) ( $b['type'] ?? '' ) ); + return array( 'EXISTS', 'NOT EXISTS' ) === $compares && $same ? $name : ''; + } + /** * Generate a deterministic JOIN alias for a (post_type, group) pair. * @@ -429,9 +750,8 @@ class TMDO_Post_Query_Router { * @return string Sanitized operator (defaults to '='). */ private static function sanitize_compare( string $compare ): string { - $allowed = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ); $compare = strtoupper( trim( $compare ) ); - return in_array( $compare, $allowed, true ) ? $compare : '='; + return in_array( $compare, self::COMPARES, true ) ? $compare : '='; } /** @@ -468,6 +788,10 @@ class TMDO_Post_Query_Router { return ' AND ' . $col_sql . ' ' . $op; } + if ( in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true ) ) { + $value = '%' . $wpdb->esc_like( (string) $value ) . '%'; + } + return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value ); } } diff --git a/includes/zones/class-tmdo-zone-router.php b/includes/zones/class-tmdo-zone-router.php index f941c60..8136cb2 100644 --- a/includes/zones/class-tmdo-zone-router.php +++ b/includes/zones/class-tmdo-zone-router.php @@ -104,7 +104,10 @@ class TMDO_Zone_Router { * * Callers only need post_id and post_type; zone enumeration and per-zone * error isolation are handled here. Hot and Cold are skipped when the - * post_type has no registered fields in that zone (table may not exist). + * post_type has no registered fields in that zone, or when the zone table + * was never created (fields are registered long before a module runs). + * Module state is not checked: a table left by a rolled-back module still + * gets its rows for deleted posts removed. * * @param int $post_id Post ID being deleted. * @param string $post_type Post type slug. @@ -113,7 +116,7 @@ class TMDO_Zone_Router { public static function delete_post( int $post_id, string $post_type ): void { $registry = TMDO_Schema_Registry::instance(); - if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) { + if ( ! empty( $registry->get_hot_columns( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Hot::table( $post_type ) ) ) { try { TMDO_Zone_Hot::delete( $post_id, $post_type ); } catch ( \Throwable $e ) { @@ -121,7 +124,7 @@ class TMDO_Zone_Router { } } - if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) { + if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Cold::table( $post_type ) ) ) { try { TMDO_Zone_Cold::delete( $post_id, $post_type ); } catch ( \Throwable $e ) { diff --git a/readme.txt b/readme.txt index a1d9fd0..2ef9863 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress Requires at least: 6.0 Tested up to: 6.9 Requires PHP: 8.1 -Stable tag: 1.0.0 +Stable tag: 1.1.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -107,6 +107,17 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency, == Changelog == += 1.1.0 = +* Search-index mode for HivePress / WooCommerce sites (docs/adr-004): native meta stays the source of truth, flat tables are its search index +* Promotion Guard: aeav_only and cleanup are refused while HivePress or WooCommerce is active (filter `tmdo_promotion_guard_allow`) +* shadow_read reads native meta and samples a flat comparison — fixes lost native writes +* dual_write / shadow_read: flat values follow native after every write (added_/updated_/deleted_ meta actions, delete_all, key change by meta_id) +* Arrays in text fields are stored serialized, never "Array"; `wp tmdo repair-array-literals` +* `wp tmdo bridge-restore-native`: overwrite in aeav_only, only-missing otherwise; no invented default rows +* Post query router: routes only when the result equals native (OR, several post types, untyped numeric compares stay native), LIKE wildcards, one join per group, no GROUP BY, HivePress `_sort` / named / meta_value_num ordering, WordPress split query kept, flat-sorted searches driven from the flat table (filter `tmdo_router_flat_first`) +* Deleting an entity no longer leaves an all-NULL flat row behind +* Zone tables that were never created are never queried + = 1.0.0 = * Supersedes `wp-data-optimizer` v3.4.6 — that plugin is retired; this one is the single unified engine * Version numbering restarts at 1.0.0; entries below 3.4.6 are the predecessor's history, kept for archaeology diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 54161d5..91cf5f2 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -613,6 +613,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php'; // Engine (v2.0.0). require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php'; @@ -646,6 +647,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php'; // Options Manager. diff --git a/tests/integration/ArrayLiteralRepairTest.php b/tests/integration/ArrayLiteralRepairTest.php new file mode 100644 index 0000000..95f0cb4 --- /dev/null +++ b/tests/integration/ArrayLiteralRepairTest.php @@ -0,0 +1,119 @@ +query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, prefs TEXT DEFAULT NULL, loc VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' ); + + $prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) ); + $wpdb->insert( self::TABLE, array( 'user_id' => 1, 'prefs' => 'Array' ) ); + $wpdb->insert( self::TABLE, array( 'user_id' => 2, 'loc' => 'Array' ) ); + $wpdb->insert( self::TABLE, array( 'user_id' => 3, 'name' => 'Array' ) ); + $wpdb->insert( self::TABLE, array( 'user_id' => 4, 'prefs' => $prefs, 'loc' => 'Taipei' ) ); + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'prefs', 'meta_value' => $prefs ) ); + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 2, 'meta_key' => 'loc', 'meta_value' => 'Array' ) ); + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'prefs', 'meta_value' => $prefs ) ); + + $GLOBALS['_wp_cache'] = array(); + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + WPDO_Entity_Registry::register_group( + 'user', + 'al_prefs', + array( + array( 'key' => 'prefs', 'type' => 'textarea' ), + array( 'key' => 'loc', 'type' => 'text' ), + array( 'key' => 'name', 'type' => 'text' ), + ) + ); + WPDO_Schema_Manager::flush_table_exists_cache(); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + } + + private static function drop(): void { + global $wpdb; + foreach ( array( $wpdb->usermeta, self::TABLE ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } + + private function flat( int $user_id, string $col ): ?string { + global $wpdb; + return $wpdb->get_var( $wpdb->prepare( "SELECT `{$col}` FROM `" . self::TABLE . '` WHERE user_id = %d', $user_id ) ); + } + + public function test_dry_run_reports_without_writing(): void { + $report = TMDO_Array_Literal_Repair::run( 'user', false ); + + $this->assertSame( 3, $report['found'] ); + $this->assertSame( 1, $report['repairable'] ); + $this->assertSame( 0, $report['repaired'] ); + $this->assertSame( 'Array', $this->flat( 1, 'prefs' ) ); + } + + public function test_apply_repairs_from_native_and_lists_the_rest(): void { + $report = TMDO_Array_Literal_Repair::run( 'user', true ); + + $this->assertSame( 1, $report['repaired'] ); + $this->assertSame( serialize( array( 'core' => array( 'welcomeGuide' => false ) ) ), $this->flat( 1, 'prefs' ) ); + $this->assertSame( 'Array', $this->flat( 2, 'loc' ), 'native is "Array" too — nothing to repair from' ); + $this->assertSame( 'Array', $this->flat( 3, 'name' ), 'no native row' ); + $this->assertSame( 'Taipei', $this->flat( 4, 'loc' ) ); + + $unrepairable = array_map( + static function ( $r ) { + return $r['id'] . ':' . $r['key'] . ':' . $r['reason']; + }, + $report['unrepairable'] + ); + sort( $unrepairable ); + $this->assertSame( array( '2:loc:native_is_array_literal', '3:name:no_native_row' ), $unrepairable ); + + $this->assertSame( array( array( 'group' => 'al_prefs', 'key' => 'loc', 'id' => 2 ) ), $report['native_polluted'] ); + } + + public function test_second_run_finds_only_the_unrepairable_rows(): void { + TMDO_Array_Literal_Repair::run( 'user', true ); + + $report = TMDO_Array_Literal_Repair::run( 'user', true ); + + $this->assertSame( 2, $report['found'] ); + $this->assertSame( 0, $report['repaired'] ); + } +} diff --git a/tests/integration/HookBusIntegrationTest.php b/tests/integration/HookBusIntegrationTest.php index 4c8c1db..b4e4fec 100644 --- a/tests/integration/HookBusIntegrationTest.php +++ b/tests/integration/HookBusIntegrationTest.php @@ -202,14 +202,89 @@ class HookBusIntegrationTest extends TestCase { $this->assertTrue( $result, 'aeav_only must return true to short-circuit EAV write' ); } - public function test_update_writes_flat_and_returns_null_in_dual_write_mode(): void { + /** + * dual_write: the pre-filter only lets WP write native; flat is synced + * from native afterwards (updated_/added_ actions → sync_from_native). + */ + public function test_update_leaves_flat_to_the_native_sync_in_dual_write_mode(): void { + global $wpdb; self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE ); $GLOBALS['_wp_current_filter'] = 'update_user_metadata'; $result = WPDO_Hook_Bus::intercept_update( null, 3, 'first_name', 'Carol', '' ); - $this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) ); $this->assertNull( $result, 'dual_write must return null so WP continues to write EAV' ); + $this->assertNull( $this->read_flat_first_name( 3 ), 'no flat write before native is written' ); + + $this->with_usermeta( + function () use ( $wpdb ) { + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 3, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ) ); + + WPDO_Hook_Bus::sync_from_native( 'user', 3, 'first_name', 'Carol', 'update' ); + + $this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) ); + } + ); + } + + /** + * delete_all passes object ID 0: the delete_ action records the touched + * users from the meta IDs, the deleted_ action re-syncs each to NULL. + */ + public function test_delete_all_resyncs_every_touched_entity_in_dual_write_mode(): void { + global $wpdb; + self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE ); + $wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 21, 'first_name' => 'A' ) ); + $wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 22, 'first_name' => 'B' ) ); + + $this->with_usermeta( + function () use ( $wpdb ) { + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 21, 'meta_key' => 'first_name', 'meta_value' => 'A' ) ); + $mid_a = (int) $wpdb->insert_id; + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 22, 'meta_key' => 'first_name', 'meta_value' => 'B' ) ); + $mid_b = (int) $wpdb->insert_id; + + $GLOBALS['_wp_current_filter'] = 'delete_user_meta'; + WPDO_Hook_Bus::before_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' ); + $wpdb->query( "DELETE FROM `{$wpdb->usermeta}` WHERE meta_key = 'first_name'" ); + $GLOBALS['_wp_current_filter'] = 'deleted_user_meta'; + WPDO_Hook_Bus::on_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' ); + + $this->assertNull( $this->read_flat_first_name( 21 ) ); + $this->assertNull( $this->read_flat_first_name( 22 ) ); + } + ); + } + + /** + * Deleting an entity drops its flat row first (adapter delete hook), then + * WordPress deletes each meta row; syncing those deletions must not bring + * the row back as all NULL. + */ + public function test_sync_of_a_deleted_key_does_not_recreate_a_missing_row(): void { + global $wpdb; + self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE ); + + $this->with_usermeta( + function () use ( $wpdb ) { + WPDO_Hook_Bus::sync_from_native( 'user', 31, 'first_name', 'Gone', 'delete' ); + + $this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '` WHERE user_id = 31' ) ); + } + ); + } + + /** + * Run $fn with a native usermeta table, dropped afterwards. + */ + private function with_usermeta( callable $fn ): void { + global $wpdb; + $wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id) ) ENGINE=InnoDB" ); + try { + $fn(); + } finally { + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" ); + } } public function test_update_passthrough_for_unregistered_key(): void { @@ -268,6 +343,30 @@ class HookBusIntegrationTest extends TestCase { $this->assertNull( $result, 'dual_write does not read from flat — must return $check' ); } + /** + * shadow_read serves native meta: update_metadata() reads the old value + * through this filter, and serving the just-written flat value made WP + * skip the native write (dev36, 2026-09-26). Flat is only compared. + */ + public function test_get_serves_native_in_shadow_read_mode_even_with_a_flat_value(): void { + global $wpdb; + $wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 12, 'first_name' => 'Flat' ) ); + self::set_user_mode( WPDO_Mode_Manager::MODE_SHADOW_READ ); + + foreach ( array( 0.0, 1.0 ) as $rate ) { + $GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] = array( + static function () use ( $rate ) { + return $rate; + }, + ); + + $result = WPDO_Hook_Bus::intercept_get( null, 12, 'first_name', true, 'user' ); + + $this->assertNull( $result, "shadow_read must return \$check (sample rate {$rate})" ); + } + unset( $GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] ); + } + public function test_get_returns_empty_string_when_column_is_null(): void { global $wpdb; $wpdb->query( diff --git a/tests/integration/NativeRestorerTest.php b/tests/integration/NativeRestorerTest.php index 436f65e..bf81167 100644 --- a/tests/integration/NativeRestorerTest.php +++ b/tests/integration/NativeRestorerTest.php @@ -139,4 +139,77 @@ class NativeRestorerTest extends TestCase { $this->assertSame( array( '9' ), $this->native( 1, 'level' ) ); } + + private function set_user_mode( string $mode ): void { + $GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => $mode ); + TMDO_Mode_Manager::reset_cache(); + } + + /** + * Native is the source of truth outside aeav_only (e.g. WooCommerce writes + * stock with direct SQL): the default policy there only fills gaps. + */ + public function test_native_authoritative_modes_only_fill_missing_rows(): void { + $this->set_user_mode( 'dual_write' ); + + $report = TMDO_Native_Restorer::run( 'user', false ); + + $this->assertSame( array( '1' ), $this->native( 1, 'level' ), 'differing native value kept' ); + $this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ), 'missing native row filled' ); + $this->assertSame( array( '5' ), $this->native( 2, 'level' ) ); + $this->assertSame( array( 'stale' ), $this->native( 2, 'tags' ), 'NULL flat value does not delete native here' ); + $this->assertSame( 'only_missing', $report['policy'] ); + $this->assertSame( 2, $report['groups']['rn_profile']['missing'] ); + $this->assertSame( 2, $report['groups']['rn_profile']['differs'] ); + } + + public function test_overwrite_policy_replaces_native_in_any_mode(): void { + $this->set_user_mode( 'dual_write' ); + + TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'overwrite' ); + + $this->assertSame( array( '3' ), $this->native( 1, 'level' ) ); + $this->assertSame( array(), $this->native( 2, 'tags' ) ); + } + + public function test_only_missing_policy_keeps_native_in_aeav_only(): void { + TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'only_missing' ); + + $this->assertSame( array( '1' ), $this->native( 1, 'level' ) ); + $this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ) ); + } + + public function test_dry_run_classifies_without_writing(): void { + $report = TMDO_Native_Restorer::run( 'user', true ); + + $this->assertSame( 'overwrite', $report['policy'], 'aeav_only: flat is the source of truth' ); + $this->assertSame( 2, $report['groups']['rn_profile']['missing'] ); + $this->assertSame( 2, $report['groups']['rn_profile']['differs'] ); + $this->assertSame( 0, $report['groups']['rn_profile']['same'] ); + $this->assertSame( array( '1' ), $this->native( 1, 'level' ) ); + } + + /** + * A flat column holding its field default for an entity that never had + * the key (e.g. activity.session_count = 0) is not written back. + */ + public function test_defaults_are_not_invented_as_native_rows(): void { + global $wpdb; + $table = 'wp_itest_wpdo_user_rn_counts'; + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + $wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, visits BIGINT(20) DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB" ); + $wpdb->query( "INSERT INTO `{$table}` (user_id, visits, updated_at) VALUES (1, 0, '2020-01-01'), (2, 7, '2020-01-01')" ); + WPDO_Entity_Registry::register_group( 'user', 'rn_counts', array( array( 'key' => 'visits', 'type' => 'integer', 'default' => 0 ) ) ); + WPDO_Schema_Manager::flush_table_exists_cache(); + + try { + $report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rn_counts' ); + + $this->assertSame( array(), $this->native( 1, 'visits' ), 'default 0 not invented' ); + $this->assertSame( array( '7' ), $this->native( 2, 'visits' ) ); + $this->assertSame( 1, $report['groups']['rn_counts']['default_skipped'] ); + } finally { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } } diff --git a/tests/integration/PostQueryRouterCopyTest.php b/tests/integration/PostQueryRouterCopyTest.php index e0695f6..2ce525a 100644 --- a/tests/integration/PostQueryRouterCopyTest.php +++ b/tests/integration/PostQueryRouterCopyTest.php @@ -58,14 +58,14 @@ class PostQueryRouterCopyTest extends TestCase { $mode->setValue( null, null ); } - /** @return array{join:string, where:string, orderby:string, groupby:string} */ + /** @return array{join:string, where:string, orderby:string} */ private function sql( array $vars ): array { $query = new WP_Query(); foreach ( array_merge( array( 'post_type' => 'rc_item', 'post_status' => 'publish', - 'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN' ) ), + 'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ), ), $vars ) as $k => $v ) { @@ -77,7 +77,6 @@ class PostQueryRouterCopyTest extends TestCase { 'join' => $router->posts_join( '', $query ), 'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ), 'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ), - 'groupby' => $router->posts_groupby( '', $query ), ); } @@ -85,7 +84,6 @@ class PostQueryRouterCopyTest extends TestCase { $this->assertStringContainsString( 'LEFT JOIN', $sql['join'] ); $this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] ); $this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] ); - $this->assertSame( '`wp_itest_posts`.`ID`', $sql['groupby'] ); } public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void { @@ -95,7 +93,6 @@ class PostQueryRouterCopyTest extends TestCase { $this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] ); $this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' ); $this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] ); - $this->assertSame( '', $sql['groupby'], 'one row per post: no GROUP BY, so LIMIT can stop early' ); } public function test_several_explicit_statuses_are_kept(): void { @@ -123,4 +120,9 @@ class PostQueryRouterCopyTest extends TestCase { $this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) ); $this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) ); } + + public function test_not_exists_keeps_the_legacy_shape(): void { + // The INNER JOIN would drop posts without a flat row, which NOT EXISTS must match. + $this->assert_legacy_shape( $this->sql( array( 'meta_query' => array( array( 'key' => 'rc_price', 'compare' => 'NOT EXISTS' ) ) ) ) ); + } } diff --git a/tests/integration/PostQueryRouterFoundRowsTest.php b/tests/integration/PostQueryRouterFoundRowsTest.php index 825f55d..78e01eb 100644 --- a/tests/integration/PostQueryRouterFoundRowsTest.php +++ b/tests/integration/PostQueryRouterFoundRowsTest.php @@ -75,12 +75,12 @@ class PostQueryRouterFoundRowsTest extends TestCase { } /** A routed query plus the SQL WordPress would build for it (page size 1). */ - private function routed(): array { + private function routed( array $clause = array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=', 'type' => 'NUMERIC' ) ): array { global $wpdb; $query = new WP_Query(); $query->set( 'post_type', 'fr_item' ); $query->set( 'post_status', 'publish' ); - $query->set( 'meta_query', array( array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=' ) ) ); + $query->set( 'meta_query', array( $clause ) ); $router = new TMDO_Post_Query_Router(); $router->pre_get_posts( $query ); @@ -133,4 +133,129 @@ class PostQueryRouterFoundRowsTest extends TestCase { $this->assertNotSame( $before, $after_write ); $this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) ); } + + // ── flat-first: a filtered search ordered by a flat column ───────────── + + /** + * A routed search ordered by the flat price (named clause), with the + * posts.ID tie-break a plugin such as HivePress appends. + * + * @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string} + */ + private function sorted( string $compare = '>=' ): array { + global $wpdb; + $query = new WP_Query(); + $query->set( 'post_type', 'fr_item' ); + $query->set( 'post_status', 'publish' ); + $query->set( 'meta_query', array( 'price' => array( 'key' => 'fr_price', 'value' => 15, 'compare' => $compare, 'type' => 'NUMERIC' ) ) ); + $query->set( 'orderby', 'price' ); + $query->set( 'order', 'DESC' ); + $router = new TMDO_Post_Query_Router(); + $router->pre_get_posts( $query ); + + $request = "SELECT SQL_CALC_FOUND_ROWS {$wpdb->posts}.ID FROM {$wpdb->posts} " . $router->posts_join( '', $query ) + . " WHERE 1=1 AND {$wpdb->posts}.post_type = 'fr_item' AND (({$wpdb->posts}.post_status = 'publish'))" . $router->posts_where( '', $query ) + . ' ORDER BY ' . $router->posts_orderby( '', $query ) . ", {$wpdb->posts}.ID DESC LIMIT 0, 10"; + return array( $router, $query, $request ); + } + + public function test_sorted_filtered_request_drives_from_the_flat_table_with_the_same_rows(): void { + global $wpdb; + list( $router, $query, $request ) = $this->sorted(); + + $sql = $router->posts_request( $request, $query ); + + $this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql ); + $this->assertStringContainsString( 'FROM `' . self::TABLE . '` AS `wpdoflat_fr_item_fr_core` INNER JOIN ' . $wpdb->posts . ' ON', $sql ); + $this->assertStringNotContainsString( 'LEFT JOIN `' . self::TABLE . '`', $sql ); + $this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id' ); + $this->assertSame( array( '3', '2' ), $wpdb->get_col( $sql ) ); + $this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $request ) ), $wpdb->get_col( $sql ) ); + } + + public function test_date_ordered_filtered_request_keeps_wordpress_join_order(): void { + list( $router, $query, $request ) = $this->routed(); + + $this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) ); + } + + public function test_flat_first_needs_a_condition_that_rejects_posts_without_a_flat_row(): void { + list( $router, $query, $request ) = $this->sorted( 'NOT EXISTS' ); + + $this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) ); + } + + public function test_flat_first_is_skipped_for_selective_post_filters(): void { + list( $router, $query, $request ) = $this->sorted(); + $query->set( 'post__in', array( 2 ) ); + + $this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) ); + } + + public function test_flat_first_can_be_turned_off(): void { + list( $router, $query, $request ) = $this->sorted(); + $off = static fn() => false; + add_filter( 'tmdo_router_flat_first', $off ); + try { + $this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) ); + } finally { + remove_filter( 'tmdo_router_flat_first', $off ); + } + } + + public function test_flat_first_keeps_the_split_query(): void { + global $wpdb; + list( $router, $query, $request ) = $this->sorted(); + $old_request = str_replace( "{$wpdb->posts}.ID FROM", "{$wpdb->posts}.* FROM", $request ); + $query->request = $router->posts_request( $old_request, $query ); + $query->set( 'posts_per_page', 10 ); + + $this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $query->request ); + $this->assertTrue( $router->split_the_query( false, $query, $old_request, array( 'fields' => "{$wpdb->posts}.*", 'limits' => 'LIMIT 0, 10' ) ) ); + } + + // ── split query: IDs first, then cached post objects ────────────────── + + /** + * WordPress's full-row request for a routed query, before and after posts_request. + * + * @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string, 3:array} + */ + private function full_rows(): array { + global $wpdb; + list( $router, $query, $request ) = $this->routed(); + $old_request = str_replace( "{$wpdb->posts}.ID FROM", "{$wpdb->posts}.* FROM", $request ); + $query->request = $router->posts_request( $old_request, $query ); + $query->set( 'posts_per_page', 1 ); + return array( $router, $query, $old_request, array( 'fields' => "{$wpdb->posts}.*", 'limits' => 'LIMIT 0, 1' ) ); + } + + public function test_routed_full_row_request_is_still_split(): void { + list( $router, $query, $old_request, $clauses ) = $this->full_rows(); + + $this->assertTrue( $router->split_the_query( false, $query, $old_request, $clauses ) ); + } + + public function test_request_changed_by_others_is_not_split(): void { + list( $router, $query, $old_request, $clauses ) = $this->full_rows(); + $query->request .= ' /* another plugin */'; + + $this->assertFalse( $router->split_the_query( false, $query, $old_request, $clauses ) ); + } + + public function test_unrouted_query_keeps_the_core_decision(): void { + $router = new TMDO_Post_Query_Router(); + + $this->assertFalse( $router->split_the_query( false, new WP_Query(), 'SELECT 1', array( 'fields' => 'wp_itest_posts.*', 'limits' => 'LIMIT 0, 1' ) ) ); + $this->assertTrue( $router->split_the_query( true, new WP_Query(), 'SELECT 1', array() ) ); + } + + public function test_split_id_request_goes_through_posts_request_too(): void { + unset( $GLOBALS['_wp_filter_callbacks']['posts_request_ids'], $GLOBALS['_wp_filter_callbacks']['split_the_query'] ); + $router = new TMDO_Post_Query_Router(); + $router->register_hooks(); + + $this->assertContains( array( $router, 'posts_request' ), $GLOBALS['_wp_filter_callbacks']['posts_request_ids'] ?? array() ); + $this->assertContains( array( $router, 'split_the_query' ), $GLOBALS['_wp_filter_callbacks']['split_the_query'] ?? array() ); + } } diff --git a/tests/integration/PostQueryRouterOrderTest.php b/tests/integration/PostQueryRouterOrderTest.php new file mode 100644 index 0000000..5653581 --- /dev/null +++ b/tests/integration/PostQueryRouterOrderTest.php @@ -0,0 +1,138 @@ +setAccessible( true ); + $cache->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); + } + + public static function tearDownAfterClass(): void { + $cache = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, null ); + WPDO_Entity_Registry::init(); + } + + /** + * @param array $vars Query vars (post_type defaults to product). + * @return array{query:WP_Query, join:string, where:string, orderby:string} + */ + private function route( array $vars ): array { + $query = new WP_Query(); + foreach ( $vars + array( 'post_type' => 'product' ) as $k => $v ) { + $query->set( $k, $v ); + } + $router = new TMDO_Post_Query_Router(); + $router->pre_get_posts( $query ); + return array( + 'query' => $query, + 'join' => $router->posts_join( '', $query ), + 'where' => $router->posts_where( '', $query ), + 'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ), + ); + } + + /** + * @param string $type Clause type. + * @return array + */ + private function sort_shape( string $type ): array { + return array( + array( + 'relation' => 'OR', + 'price__order' => array( 'key' => '_price', 'type' => $type, 'compare' => 'NOT EXISTS' ), + array( 'key' => '_price', 'type' => $type, 'compare' => 'EXISTS' ), + ), + ); + } + + public function test_sort_shape_left_joins_and_orders_by_the_flat_column_without_filtering(): void { + $sql = $this->route( array( 'meta_query' => $this->sort_shape( 'DECIMAL' ), 'orderby' => 'price__order', 'order' => 'ASC' ) ); + + $this->assertSame( array(), $sql['query']->get( 'meta_query' ), 'sort group removed from the native meta_query' ); + $this->assertStringContainsString( 'LEFT JOIN `wp_itest_wpdo_post_wc_product` AS ' . self::ALIAS, $sql['join'] ); + $this->assertStringNotContainsString( '_price', $sql['where'], 'every post matches: no condition' ); + $this->assertStringStartsWith( self::ALIAS . '.`_price` ASC', $sql['orderby'] ); + } + + public function test_sort_shape_with_char_type_on_a_numeric_column_stays_native(): void { + $vars = array( 'meta_query' => $this->sort_shape( 'CHAR' ), 'orderby' => 'price__order', 'order' => 'ASC' ); + $sql = $this->route( $vars ); + + $this->assertSame( $vars['meta_query'], $sql['query']->get( 'meta_query' ) ); + $this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] ); + } + + public function test_orderby_on_a_named_routed_clause_orders_by_its_column(): void { + $sql = $this->route( + array( + 'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ), + 'orderby' => array( 'price' => 'DESC' ), + ) + ); + + $this->assertStringContainsString( self::ALIAS . '.`_price` IS NOT NULL', $sql['where'] ); + $this->assertStringStartsWith( self::ALIAS . '.`_price` DESC', $sql['orderby'] ); + } + + public function test_orderby_naming_a_routed_clause_among_other_keys_stays_native(): void { + $vars = array( + 'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ), + 'orderby' => array( 'price' => 'DESC', 'title' => 'ASC' ), + ); + $sql = $this->route( $vars ); + + $this->assertSame( $vars['meta_query'], $sql['query']->get( 'meta_query' ) ); + $this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] ); + } + + public function test_meta_value_num_on_a_numeric_key_requires_the_key_and_orders_by_its_column(): void { + $sql = $this->route( array( 'meta_key' => '_price', 'orderby' => 'meta_value_num', 'order' => 'DESC' ) ); + + $this->assertSame( '', $sql['query']->get( 'meta_key' ), 'primary meta clause taken over' ); + $this->assertStringContainsString( self::ALIAS . '.`_price` IS NOT NULL', $sql['where'], 'native INNER JOIN drops posts without the key' ); + $this->assertStringStartsWith( self::ALIAS . '.`_price` DESC', $sql['orderby'] ); + } + + public function test_meta_value_on_a_string_key_is_routed(): void { + $sql = $this->route( array( 'meta_key' => '_sku', 'orderby' => 'meta_value', 'order' => 'ASC' ) ); + + $this->assertStringStartsWith( self::ALIAS . '.`_sku` ASC', $sql['orderby'] ); + } + + public function test_meta_value_on_a_numeric_key_stays_native(): void { + $sql = $this->route( array( 'meta_key' => '_price', 'orderby' => 'meta_value', 'order' => 'ASC' ) ); + + $this->assertSame( '_price', $sql['query']->get( 'meta_key' ), 'native string order' ); + $this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] ); + } + + public function test_meta_key_with_a_meta_value_filter_stays_native(): void { + $sql = $this->route( array( 'meta_key' => '_price', 'meta_value' => '5', 'orderby' => 'meta_value_num' ) ); + + $this->assertSame( '_price', $sql['query']->get( 'meta_key' ) ); + $this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] ); + } +} diff --git a/tests/integration/PostQueryRouterTest.php b/tests/integration/PostQueryRouterTest.php index 762c2ef..9c16205 100644 --- a/tests/integration/PostQueryRouterTest.php +++ b/tests/integration/PostQueryRouterTest.php @@ -78,7 +78,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), ), ) ); $original = $query->get( 'meta_query' ); @@ -100,7 +100,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), ), ) ); $original = $query->get( 'meta_query' ); @@ -123,7 +123,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), ), ) ); @@ -149,7 +149,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), array( 'key' => 'unmanaged_attr', 'value' => 'x' ), ), ) ); @@ -177,7 +177,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), ), ) ); $original = $query->get( 'meta_query' ); @@ -202,7 +202,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '50' ), + array( 'key' => '_price', 'value' => '50', 'type' => 'NUMERIC' ), ), 'wpdo_post_clauses' => array(), ) ); @@ -214,6 +214,34 @@ class PostQueryRouterTest extends TestCase { $this->assertStringContainsString( 'wpdo_post_wc_product', $join ); } + public function test_posts_join_joins_every_routed_group(): void { + $this->set_post_mode( 'aeav_only' ); + + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), + array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ), + ), + ) ); + + $router->pre_get_posts( $query ); + $join = $router->posts_join( '', $query ); + $where = $router->posts_where( '', $query ); + + $this->assertStringContainsString( 'wpdo_post_wc_product', $join ); + $this->assertStringContainsString( 'wpdo_post_wp_core', $join ); + $this->assertStringContainsString( '`wpdoflat_product_wp_core`.`_thumbnail_id` IS NOT NULL', $where ); + } + + public function test_no_group_by_is_added_for_one_row_per_post_joins(): void { + unset( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] ); + ( new WPDO_Post_Query_Router() )->register_hooks(); + + $this->assertEmpty( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] ?? array() ); + } + public function test_posts_where_appends_condition_for_routed_clause(): void { $this->set_post_mode( 'aeav_only' ); @@ -221,7 +249,7 @@ class PostQueryRouterTest extends TestCase { $query = $this->make_query( array( 'post_type' => 'product', 'meta_query' => array( - array( 'key' => '_price', 'value' => '99', 'compare' => '=' ), + array( 'key' => '_price', 'value' => '99', 'compare' => '=', 'type' => 'NUMERIC' ), ), ) ); @@ -242,4 +270,145 @@ class PostQueryRouterTest extends TestCase { $this->assertSame( '', $query->get( 'wpdo_post_clauses' ) ); } + + // ── all-or-nothing: native unless every registered clause is routable ──── + + /** + * Run pre_get_posts in aeav_only and assert the query was left to WordPress. + * + * @param array $vars Query vars. + */ + private function assert_not_routed( array $vars, string $why ): void { + $this->set_post_mode( 'aeav_only' ); + $query = $this->make_query( $vars ); + ( new WPDO_Post_Query_Router() )->pre_get_posts( $query ); + + $this->assertSame( $vars['meta_query'], $query->get( 'meta_query' ), $why . ': meta_query untouched.' ); + $this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $why . ': nothing routed.' ); + } + + public function test_or_relation_is_not_routed(): void { + $this->assert_not_routed( + array( + 'post_type' => 'product', + 'meta_query' => array( + 'relation' => 'OR', + array( 'key' => '_stock', 'value' => '1' ), + array( 'key' => '_sku', 'value' => 'x' ), + ), + ), + 'OR' + ); + } + + public function test_several_post_types_are_not_routed(): void { + $this->assert_not_routed( + array( + 'post_type' => array( 'product', 'post' ), + 'meta_query' => array( array( 'key' => '_price', 'value' => '5', 'compare' => '>=', 'type' => 'NUMERIC' ) ), + ), + 'several post types' + ); + } + + public function test_untyped_range_on_a_numeric_column_is_not_routed(): void { + $this->assert_not_routed( + array( + 'post_type' => 'product', + 'meta_query' => array( array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ) ), + ), + 'CHAR > on DECIMAL' + ); + } + + public function test_untyped_equality_on_a_decimal_column_is_not_routed(): void { + $this->assert_not_routed( + array( + 'post_type' => 'product', + 'meta_query' => array( array( 'key' => '_price', 'value' => '10' ) ), + ), + 'CHAR = on DECIMAL (native "10.00" != "10")' + ); + } + + public function test_numeric_type_on_a_text_column_is_not_routed(): void { + $this->assert_not_routed( + array( + 'post_type' => 'product', + 'meta_query' => array( array( 'key' => '_sku', 'value' => '5', 'compare' => '>', 'type' => 'NUMERIC' ) ), + ), + 'NUMERIC on VARCHAR' + ); + } + + public function test_unknown_compare_is_not_routed(): void { + $this->assert_not_routed( + array( + 'post_type' => 'product', + 'meta_query' => array( array( 'key' => '_sku', 'value' => '^a', 'compare' => 'REGEXP' ) ), + ), + 'REGEXP' + ); + } + + public function test_one_unroutable_registered_clause_keeps_the_whole_query_native(): void { + $this->assert_not_routed( + array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ), + array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ), + ), + ), + 'one unroutable clause' + ); + } + + public function test_nested_group_with_a_registered_key_is_not_routed(): void { + $this->assert_not_routed( + array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ), + array( + 'relation' => 'OR', + array( 'key' => '_price', 'compare' => 'NOT EXISTS' ), + array( 'key' => '_price', 'compare' => 'EXISTS' ), + ), + ), + ), + 'nested group' + ); + } + + public function test_untyped_integer_equality_on_an_integer_column_is_routed(): void { + $this->set_post_mode( 'aeav_only' ); + $query = $this->make_query( + array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_stock', 'value' => '3' ), + array( 'key' => 'unmanaged_attr', 'value' => 'x' ), + ), + ) + ); + ( new WPDO_Post_Query_Router() )->pre_get_posts( $query ); + + $this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) ); + $this->assertCount( 1, $query->get( 'meta_query' ), 'unregistered AND clause stays native' ); + } + + public function test_like_matches_an_escaped_substring(): void { + $this->set_post_mode( 'aeav_only' ); + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( + array( + 'post_type' => 'product', + 'meta_query' => array( array( 'key' => '_sku', 'value' => 'B1_1%', 'compare' => 'LIKE' ) ), + ) + ); + $router->pre_get_posts( $query ); + + $this->assertStringContainsString( "LIKE '%B1\\\\_1\\\\%%'", $router->posts_where( '', $query ) ); + } } diff --git a/tests/integration/PostStatusSyncTest.php b/tests/integration/PostStatusSyncTest.php index f4f08f7..b4b4310 100644 --- a/tests/integration/PostStatusSyncTest.php +++ b/tests/integration/PostStatusSyncTest.php @@ -160,7 +160,8 @@ class PostStatusSyncTest extends TestCase { TMDO_Post_Status_Sync::run(); $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); $mode->setAccessible( true ); - $mode->setValue( null, array( 'post' => 'dual_write', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); + // aeav_only: the pre-filter itself writes flat (dual_write syncs from native after the write). + $mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); $GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' ); $GLOBALS['_wp_current_filter'] = 'update_post_metadata'; diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index c19554b..1953369 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -418,6 +418,7 @@ if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key, if ( ! class_exists( 'WP_Query' ) ) { class WP_Query { + public string $request = ''; private array $vars = []; public function get( string $key, $default = '' ) { return $this->vars[ $key ] ?? $default; } public function set( string $key, $value ): void { $this->vars[ $key ] = $value; } @@ -576,6 +577,7 @@ require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php'; require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php'; +require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php'; @@ -610,6 +612,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php'; require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php'; require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php'; require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php'; diff --git a/tests/live/README.md b/tests/live/README.md new file mode 100644 index 0000000..3d772db --- /dev/null +++ b/tests/live/README.md @@ -0,0 +1,59 @@ +# Live test suite + +Tests that must run inside a real WordPress site, because the stubbed +`tests/integration` bootstrap cannot reproduce: + +- WP core meta flows (`update_metadata()` reads the old value through + `get_metadata_raw()` before writing — the shadow_read write-loss bug lives there) +- the SQL that `WP_Query` actually builds, and hook priorities +- HivePress / WooCommerce behaviour (`pre_get_posts` sort clauses, empty-key + meta reads, direct-SQL stock updates) + +**Never run against a production site.** Suites create and delete fixtures +(`tmdo_live_*` users, `tmdo-live-*` terms). Entity modes are overridden in +process through `pre_option_wpdo_bridge_modes`; nothing is written to +`wpdo_bridge_modes` or `wpdo_hook_bus_enabled`. + +The reference site is dev36 (HivePress + Marketplace + WooCommerce), with this +plugin symlinked from the `plugins-dev` worktree while developing. + +## Run + +```bash +wp --path=/var/www/Studio/wp-local-dev36 eval-file tests/live/run.php [ ...] +``` + +Exit code 0 when every test passed, 1 otherwise. Output per test: + +| Status | Meaning | +|---|---| +| PASS / FAIL | normal | +| XFAIL | known bug, test marked `xfail` with the PR that fixes it; not counted as a failure | +| XPASS | an `xfail` test passed — counted as a failure so the fixing PR removes the marker | +| SKIP | the site does not register the key / taxonomy the test needs | + +## Suites + +| Suite | Covers | +|---|---| +| `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) | +| `serialized` | array meta values are stored serialized in flat tables, never "Array" | +| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) | +| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route, meta orderby: HivePress sort shape, named clause, meta_key + meta_value_num); routed SQL has no GROUP BY | +| `zero_side_effect` | zone tables that were never created are never queried on save / delete | + +## Tools (`tools/`) + +Benchmark and data-flow helpers used for the 2026-09-26 dev36 integration test. +Output files go to the current directory (`qbench`) or `$TMDO_LIVE_OUT` +(default `/tmp`, `run_batch.sh`), never into the repo. + +| Tool | Use | +|---|---| +| `seed.php` | `wp eval-file seed.php [tag]` — seed through real HivePress / WC APIs | +| `run_batch.sh` | `run_batch.sh [divisor]` — parallel seeding batch (`TMDO_LIVE_WP_PATH`, `TMDO_LIVE_OUT`) | +| `clone.sql` | bench only: clone hp_listing rows to reach large scale (`sed "s/__K__//" clone.sql \| wp db query`) | +| `checker.php` | `wp eval-file checker.php [entity]` — native vs flat consistency per registered key (SQL only) | +| `qbench.php` / `qdiff.php` | run 16 WP_Query shapes, then diff two runs (native vs routed): result sets, order, latency, SQL errors | +| `mut.php` | data-flow mutations through HivePress models, WC CRUD and core APIs; prints native / flat / API values | +| `fe.sh` + `zz-s36-probe.php` | front-end search probe (`TMDO_LIVE_URL`). Copy the probe into `wp-content/mu-plugins/` only for the run and remove it afterwards; it only answers requests carrying `X-S36-Probe: 1` | diff --git a/tests/live/lib/assert.php b/tests/live/lib/assert.php new file mode 100644 index 0000000..50c1f6f --- /dev/null +++ b/tests/live/lib/assert.php @@ -0,0 +1,130 @@ + + */ + private static array $tests = array(); + + /** + * Suite currently being loaded. + * + * @var string + */ + public static string $suite = ''; + + /** + * Register a test. + * + * `xfail` marks a known bug: a failure is reported as XFAIL (not counted), + * a pass is reported as XPASS and counted as a failure so the marker gets + * removed by the PR that fixes the bug. + * + * @param string $name Test name. + * @param callable $fn Test body. + * @param array $opts Options: xfail => reason. + */ + public static function test( string $name, callable $fn, array $opts = array() ): void { + self::$tests[] = array( + 'suite' => self::$suite, + 'name' => $name, + 'fn' => $fn, + 'xfail' => (string) ( $opts['xfail'] ?? '' ), + ); + } + + /** + * Run every registered test; return true when nothing failed. + */ + public static function run(): bool { + $counts = array( 'PASS' => 0, 'FAIL' => 0, 'XFAIL' => 0, 'XPASS' => 0, 'SKIP' => 0 ); + + foreach ( self::$tests as $t ) { + $detail = ''; + try { + ( $t['fn'] )(); + $status = '' !== $t['xfail'] ? 'XPASS' : 'PASS'; + } catch ( TMDO_Live_Skip $e ) { + $status = 'SKIP'; + $detail = $e->getMessage(); + } catch ( Throwable $e ) { + $status = '' !== $t['xfail'] ? 'XFAIL' : 'FAIL'; + $detail = ( $e instanceof TMDO_Live_Failure ? '' : get_class( $e ) . ': ' ) . $e->getMessage(); + } finally { + TMDO_Live_Fixtures::teardown(); + } + + ++$counts[ $status ]; + if ( '' !== $t['xfail'] ) { + $detail = trim( $detail . ' [xfail: ' . $t['xfail'] . ']' ); + } + printf( "%-5s %s :: %s%s\n", $status, $t['suite'], $t['name'], '' !== $detail ? "\n " . $detail : '' ); + } + + printf( + "\n%d passed, %d failed, %d xfail, %d xpass, %d skipped\n", + $counts['PASS'], + $counts['FAIL'], + $counts['XFAIL'], + $counts['XPASS'], + $counts['SKIP'] + ); + + return 0 === $counts['FAIL'] && 0 === $counts['XPASS']; + } +} + +/** + * Fail unless $actual is identical to $expected. + * + * @param mixed $expected Expected value. + * @param mixed $actual Actual value. + * @param string $message Context. + */ +function tmdo_live_assert_same( $expected, $actual, string $message ): void { + if ( $expected !== $actual ) { + throw new TMDO_Live_Failure( + sprintf( '%s — expected %s, got %s', $message, var_export( $expected, true ), var_export( $actual, true ) ) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export + ); + } +} + +/** + * Fail unless $condition is true. + * + * @param bool $condition Condition. + * @param string $message Context. + */ +function tmdo_live_assert_true( bool $condition, string $message ): void { + if ( ! $condition ) { + throw new TMDO_Live_Failure( $message ); + } +} diff --git a/tests/live/lib/fixtures.php b/tests/live/lib/fixtures.php new file mode 100644 index 0000000..985b0bd --- /dev/null +++ b/tests/live/lib/fixtures.php @@ -0,0 +1,243 @@ + + */ + private static array $created = array(); + + /** + * Active pre_option callback, if any. + * + * @var callable|null + */ + private static $mode_filter = null; + + /** + * Override entity modes for this process and make sure the Hook Bus is hooked. + * + * @param array $modes e.g. array( 'user' => 'shadow_read' ). + */ + public static function use_modes( array $modes ): void { + self::clear_mode_filter(); + + $stored = get_option( 'wpdo_bridge_modes', array() ); + $merged = array_merge( is_array( $stored ) ? $stored : array(), $modes ); + self::$mode_filter = static function () use ( $merged ) { + return $merged; + }; + add_filter( 'pre_option_wpdo_bridge_modes', self::$mode_filter ); + self::reset_caches(); + + // The Hook Bus only boots when wpdo_hook_bus_enabled is on; the suite + // must not depend on (or change) that site option. + if ( false === has_filter( 'update_user_metadata', array( 'TMDO_Hook_Bus', 'intercept_update' ) ) ) { + TMDO_Hook_Bus::init(); + } + } + + /** + * Create a throwaway user. + */ + public static function user(): int { + $login = 'tmdo_live_' . wp_generate_password( 6, false, false ); + $id = wp_insert_user( + array( + 'user_login' => $login, + 'user_email' => $login . '@example.test', + 'user_pass' => wp_generate_password(), + ) + ); + if ( is_wp_error( $id ) ) { + throw new TMDO_Live_Failure( 'fixture user: ' . $id->get_error_message() ); + } + self::$created[] = array( 'user', (int) $id ); + return (int) $id; + } + + /** + * Create a throwaway term. + * + * @param string $taxonomy Taxonomy. + */ + public static function term( string $taxonomy ): int { + if ( ! taxonomy_exists( $taxonomy ) ) { + throw new TMDO_Live_Skip( "taxonomy {$taxonomy} not registered on this site" ); + } + $t = wp_insert_term( 'tmdo-live-' . wp_generate_password( 6, false, false ), $taxonomy ); + if ( is_wp_error( $t ) ) { + throw new TMDO_Live_Failure( 'fixture term: ' . $t->get_error_message() ); + } + self::$created[] = array( 'term', (int) $t['term_id'], $taxonomy ); + return (int) $t['term_id']; + } + + /** + * Create a throwaway post. + * + * @param string $post_type Post type. + */ + public static function post( string $post_type ): int { + if ( ! post_type_exists( $post_type ) ) { + throw new TMDO_Live_Skip( "post type {$post_type} not registered on this site" ); + } + $id = wp_insert_post( + array( + 'post_type' => $post_type, + 'post_title' => 'tmdo-live-' . wp_generate_password( 6, false, false ), + 'post_status' => 'draft', + ), + true + ); + if ( is_wp_error( $id ) ) { + throw new TMDO_Live_Failure( 'fixture post: ' . $id->get_error_message() ); + } + self::$created[] = array( 'post', (int) $id ); + return (int) $id; + } + + /** + * Create a throwaway comment (on a throwaway post of $post_type). + * + * @param string $post_type Post type of the parent post. + * @param string $comment_type Comment type. + */ + public static function comment( string $post_type, string $comment_type = 'comment' ): int { + $post_id = self::post( $post_type ); + $id = wp_insert_comment( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => 'tmdo-live', + 'comment_type' => $comment_type, + 'comment_approved' => 1, + ) + ); + if ( ! $id ) { + throw new TMDO_Live_Failure( 'fixture comment could not be created' ); + } + self::$created[] = array( 'comment', (int) $id ); + return (int) $id; + } + + /** + * Native meta values for a key, read with SQL (no filters, no cache). + * + * @param string $type Entity type (post|user|term|comment). + * @param int $id Entity ID. + * @param string $key Meta key. + * @return string[] + */ + public static function native( string $type, int $id, string $key ): array { + global $wpdb; + $table = _get_meta_table( $type ); + $id_col = $type . '_id'; + $pk = 'user' === $type ? 'umeta_id' : 'meta_id'; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column names are WP internals. + return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$table} WHERE {$id_col} = %d AND meta_key = %s ORDER BY {$pk}", $id, $key ) ); + } + + /** + * Raw flat-table value for a key; 'NO-ROW' when the entity has no row. + * + * @param string $type Entity type. + * @param int $id Entity ID. + * @param string $key Meta key. + * @param string $post_type For posts: the post type whose group owns the key. + * @return string|null + */ + public static function flat( string $type, int $id, string $key, string $post_type = '' ) { + global $wpdb; + $field = self::field( $type, $key, $post_type ); + $table = TMDO_Schema_Manager::get_table_name( $type, $field['group'] ); + $col = TMDO_Schema_Manager::sanitize_column_name( $key ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column come from the registry. + $row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$col}` AS v FROM `{$table}` WHERE {$type}_id = %d", $id ), ARRAY_A ); + return null === $row ? 'NO-ROW' : $row['v']; + } + + /** + * Registry field for a key, or skip the test when the site does not register it. + * + * A key can live in several post groups (hp_rating in listing and vendor + * groups); pass the post type to pick the group scoped to it. + * + * @param string $type Entity type. + * @param string $key Meta key. + * @param string $post_type For posts: the post type whose group owns the key. + */ + public static function field( string $type, string $key, string $post_type = '' ): array { + $field = TMDO_Entity_Registry::get_field( $type, $key ); + if ( '' !== $post_type ) { + foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) { + if ( in_array( $post_type, TMDO_Entity_Registry::get_group_post_types( $type, $group ), true ) + && in_array( $key, TMDO_Entity_Registry::get_group_keys( $type, $group ), true ) ) { + $field = array( 'group' => $group ) + (array) $field; + break; + } + } + } + if ( ! $field ) { + throw new TMDO_Live_Skip( "{$type} key {$key} is not registered on this site" ); + } + if ( ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( $type, $field['group'] ) ) ) { + throw new TMDO_Live_Skip( "flat table for {$type}/{$field['group']} does not exist" ); + } + return $field; + } + + /** + * Delete fixtures and drop the mode override. + */ + public static function teardown(): void { + require_once ABSPATH . 'wp-admin/includes/user.php'; + foreach ( array_reverse( self::$created ) as $c ) { + if ( 'user' === $c[0] ) { + wp_delete_user( $c[1] ); + } elseif ( 'term' === $c[0] ) { + wp_delete_term( $c[1], $c[2] ); + } elseif ( 'post' === $c[0] ) { + wp_delete_post( $c[1], true ); + } elseif ( 'comment' === $c[0] ) { + wp_delete_comment( $c[1], true ); + } + } + self::$created = array(); + self::clear_mode_filter(); + self::reset_caches(); + } + + /** + * Remove the pre_option override, if any. + */ + private static function clear_mode_filter(): void { + if ( null !== self::$mode_filter ) { + remove_filter( 'pre_option_wpdo_bridge_modes', self::$mode_filter ); + self::$mode_filter = null; + } + } + + /** + * Reset request-level caches that memoise modes or routing decisions. + */ + private static function reset_caches(): void { + TMDO_Mode_Manager::reset_cache(); + TMDO_Routing_Predicate::flush_cache(); + } +} diff --git a/tests/live/run.php b/tests/live/run.php new file mode 100644 index 0000000..09a76f9 --- /dev/null +++ b/tests/live/run.php @@ -0,0 +1,49 @@ + eval-file tests/live/run.php [ ...] + * + * Exit code 0 when every test passed (XFAIL allowed), 1 otherwise. + * Never run against a production site: suites create and delete fixtures. + * + * No strict_types here: wp eval-file evals this file, and a declare must be + * the first statement of a real file. The required files declare it. + * + * @package WP_Data_Optimizer + */ + +require __DIR__ . '/lib/assert.php'; +require __DIR__ . '/lib/fixtures.php'; + +$tmdo_live_suites = isset( $args ) ? (array) $args : array(); + +if ( ! class_exists( 'TMDO_Hook_Bus' ) ) { + fwrite( STDERR, "2meet-data-optimizer is not active on this site.\n" ); + exit( 1 ); +} + +if ( empty( $tmdo_live_suites ) ) { + $tmdo_live_available = array_map( + static function ( $f ) { + return basename( $f, '.php' ); + }, + glob( __DIR__ . '/suites/*.php' ) + ); + fwrite( STDERR, 'Usage: wp eval-file tests/live/run.php ... Suites: ' . implode( ', ', $tmdo_live_available ) . "\n" ); + exit( 1 ); +} + +foreach ( $tmdo_live_suites as $tmdo_live_suite ) { + $tmdo_live_file = __DIR__ . '/suites/' . basename( (string) $tmdo_live_suite ) . '.php'; + if ( ! is_file( $tmdo_live_file ) ) { + fwrite( STDERR, "Unknown suite: {$tmdo_live_suite}\n" ); + exit( 1 ); + } + TMDO_Live::$suite = (string) $tmdo_live_suite; + require $tmdo_live_file; +} + +exit( TMDO_Live::run() ? 0 : 1 ); diff --git a/tests/live/suites/resync.php b/tests/live/suites/resync.php new file mode 100644 index 0000000..75a0657 --- /dev/null +++ b/tests/live/suites/resync.php @@ -0,0 +1,138 @@ + + */ +function tmdo_live_resync_cases(): array { + return array( + 'user first_name' => array( 'user', 'first_name', array( 'TMDO_Live_Fixtures', 'user' ), '' ), + 'hp_listing price' => array( 'post', 'hp_price', static fn() => TMDO_Live_Fixtures::post( 'hp_listing' ), 'hp_listing' ), + 'hp term icon' => array( 'term', 'hp_icon', static fn() => TMDO_Live_Fixtures::term( 'hp_listing_category' ), '' ), + 'hp review rating' => array( 'comment', 'hp_rating', static fn() => TMDO_Live_Fixtures::comment( 'hp_listing', 'hp_review' ), '' ), + ); +} + +/** + * Assert flat equals the first native value (numeric-aware; missing native = NULL flat). + */ +function tmdo_live_assert_in_step( string $type, int $id, string $key, string $post_type, string $context ): void { + $native = TMDO_Live_Fixtures::native( $type, $id, $key ); + $flat = TMDO_Live_Fixtures::flat( $type, $id, $key, $post_type ); + $expect = $native ? $native[0] : null; + if ( 'NO-ROW' === $flat ) { + $flat = null; + } + $same = ( null === $expect || null === $flat ) + ? $expect === $flat + : ( is_numeric( $expect ) && is_numeric( $flat ) ? (float) $expect === (float) $flat : $expect === $flat ); + tmdo_live_assert_true( $same, sprintf( '%s — native %s, flat %s', $context, var_export( $expect, true ), var_export( $flat, true ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export +} + +foreach ( array( 'dual_write', 'shadow_read' ) as $tmdo_live_mode ) { + foreach ( tmdo_live_resync_cases() as $tmdo_live_label => $tmdo_live_case ) { + list( $type, $key, $make, $pt ) = $tmdo_live_case; + + TMDO_Live::test( + "{$tmdo_live_mode} {$tmdo_live_label}: update, same value, delete", + static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) { + TMDO_Live_Fixtures::field( $type, $key, $pt ); + TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) ); + $id = $make(); + + update_metadata( $type, $id, $key, '11' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'new key' ); + update_metadata( $type, $id, $key, '12' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'update' ); + update_metadata( $type, $id, $key, '12' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'same value' ); + delete_metadata( $type, $id, $key, '12' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'delete with value' ); + } + ); + + TMDO_Live::test( + "{$tmdo_live_mode} {$tmdo_live_label}: writes WP refuses leave flat alone", + static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) { + TMDO_Live_Fixtures::field( $type, $key, $pt ); + TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) ); + $id = $make(); + update_metadata( $type, $id, $key, '21' ); + + update_metadata( $type, $id, $key, '22', 'no-such-previous-value' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'non-matching prev_value' ); + add_metadata( $type, $id, $key, '23', true ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'add unique on existing key' ); + update_metadata( $type, $id, $key, '24', '21' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'matching prev_value' ); + } + ); + + TMDO_Live::test( + "{$tmdo_live_mode} {$tmdo_live_label}: stale flat heals on the next write, key change by meta_id", + static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) { + global $wpdb; + TMDO_Live_Fixtures::field( $type, $key, $pt ); + TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) ); + $id = $make(); + update_metadata( $type, $id, $key, '31' ); + + $table = _get_meta_table( $type ); + $id_col = $type . '_id'; + $pk = 'user' === $type ? 'umeta_id' : 'meta_id'; + + // A direct SQL write (as WooCommerce does for stock) leaves flat stale. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP meta table names. + $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET meta_value = '32' WHERE {$id_col} = %d AND meta_key = %s", $id, $key ) ); + wp_cache_delete( $id, $type . '_meta' ); + update_metadata( $type, $id, $key, '33' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'next write after a direct SQL change' ); + + // Renaming the key by meta_id removes it from native: flat must follow. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP meta table names. + $mid = (int) $wpdb->get_var( $wpdb->prepare( "SELECT {$pk} FROM {$table} WHERE {$id_col} = %d AND meta_key = %s", $id, $key ) ); + update_metadata_by_mid( $type, $mid, '34', 'tmdo_live_renamed' ); + tmdo_live_assert_in_step( $type, $id, $key, $pt, 'key renamed by meta_id' ); + } + ); + } +} + +TMDO_Live::test( + 'deleting an entity leaves no flat row behind (dual_write and shadow_read)', + static function () { + require_once ABSPATH . 'wp-admin/includes/user.php'; + $deleters = array( + 'user' => static fn( int $id ) => wp_delete_user( $id ), + 'post' => static fn( int $id ) => wp_delete_post( $id, true ), + 'term' => static fn( int $id ) => wp_delete_term( $id, 'hp_listing_category' ), + 'comment' => static fn( int $id ) => wp_delete_comment( $id, true ), + ); + foreach ( array( 'dual_write', 'shadow_read' ) as $mode ) { + foreach ( tmdo_live_resync_cases() as $label => $case ) { + list( $type, $key, $make, $pt ) = $case; + TMDO_Live_Fixtures::field( $type, $key, $pt ); + TMDO_Live_Fixtures::use_modes( array( $type => $mode ) ); + $id = $make(); + update_metadata( $type, $id, $key, '41' ); + + $deleters[ $type ]( $id ); + + tmdo_live_assert_same( 'NO-ROW', TMDO_Live_Fixtures::flat( $type, $id, $key, $pt ), "{$mode} {$label}: flat row after delete" ); + } + } + } +); diff --git a/tests/live/suites/router.php b/tests/live/suites/router.php new file mode 100644 index 0000000..0ce0a6e --- /dev/null +++ b/tests/live/suites/router.php @@ -0,0 +1,425 @@ + $args Query args (meta_query, post_type, orderby …). + * @param int[] $ids Fixture posts to search within. + * @param bool $routed Hook a TMDO_Post_Query_Router for this query. + * @return array{ids:int[], routed:bool, sql:string} + */ +function tmdo_live_router_query( array $args, array $ids, bool $routed ): array { + global $wpdb; + $router = new TMDO_Post_Query_Router(); + if ( $routed ) { + $router->register_hooks(); + } + try { + $wpdb->last_error = ''; + $q = new WP_Query( + $args + array( + 'post__in' => $ids, + 'post_status' => 'any', + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'ASC', + 'fields' => 'ids', + 'no_found_rows' => true, + 'cache_results' => false, + 'suppress_filters' => false, + ) + ); + } finally { + tmdo_live_router_unhook( $router ); + } + tmdo_live_assert_same( '', $wpdb->last_error, 'SQL error' ); + return array( + 'ids' => array_map( 'intval', $q->posts ), + 'routed' => ! empty( $q->get( 'wpdo_post_clauses' ) ) || ! empty( $q->get( 'wpdo_post_sort' ) ), + 'sql' => (string) $q->request, + ); +} + +/** + * Remove every hook TMDO_Post_Query_Router::register_hooks() added. + * + * @param TMDO_Post_Query_Router $router Router instance. + */ +function tmdo_live_router_unhook( TMDO_Post_Query_Router $router ): void { + foreach ( array( 'pre_get_posts', 'posts_join', 'posts_where', 'posts_orderby', 'posts_request', 'split_the_query', 'found_posts_query' ) as $method ) { + foreach ( 'posts_request' === $method ? array( 'posts_request', 'posts_request_ids' ) : array( $method ) as $hook ) { + $priority = has_filter( $hook, array( $router, $method ) ); + if ( false !== $priority ) { + remove_filter( $hook, array( $router, $method ), $priority ); + } + } + } +} + +/** + * Create posts of $post_type with the given meta, in post mode shadow_read + * (so the Hook Bus keeps the flat rows in step). + * + * @param string $post_type Post type. + * @param array> $posts Label => meta. + * @return array Label => post id. + */ +function tmdo_live_router_posts( string $post_type, array $posts ): array { + $ids = array(); + foreach ( $posts as $label => $meta ) { + $ids[ $label ] = TMDO_Live_Fixtures::post( $post_type ); + foreach ( $meta as $key => $value ) { + update_post_meta( $ids[ $label ], $key, $value ); + } + } + return $ids; +} + +/** + * Listing fixtures shared by the hp_listing cases. + * + * @return array + */ +function tmdo_live_router_listings(): array { + foreach ( array( 'hp_price', 'hp_verified', 'hp_featured', 'hp_hourly_rate' ) as $key ) { + TMDO_Live_Fixtures::field( 'post', $key, 'hp_listing' ); + } + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + return tmdo_live_router_posts( + 'hp_listing', + array( + 'L1' => array( 'hp_price' => '10000', 'hp_featured' => '1', 'hp_verified' => '0', 'hp_hourly_rate' => '40' ), + 'L2' => array( 'hp_price' => '9500', 'hp_verified' => '1', 'hp_hourly_rate' => '30' ), + 'L3' => array( 'hp_price' => '500', 'hp_featured' => '0', 'hp_hourly_rate' => '45' ), + 'L4' => array( 'hp_price' => '20000', 'tmdo_live_unregistered' => '0' ), + 'L5' => array(), + ) + ); +} + +/** + * Assert the routed query equals native; optionally that it was routed at all. + * + * @param array $args Query args. + * @param int[] $ids Fixture ids. + * @param bool|null $expect_routed true / false to assert, null to ignore. + * @param bool $ordered Compare order too (false when the order has ties). + */ +function tmdo_live_router_assert_native( array $args, array $ids, ?bool $expect_routed = null, bool $ordered = true ): void { + $native = tmdo_live_router_query( $args, $ids, false ); + $routed = tmdo_live_router_query( $args, $ids, true ); + if ( ! $ordered ) { + sort( $native['ids'] ); + sort( $routed['ids'] ); + } + tmdo_live_assert_same( $native['ids'], $routed['ids'], 'routed ids vs native' ); + if ( null !== $expect_routed ) { + tmdo_live_assert_same( $expect_routed, $routed['routed'], 'query routed to the flat table' ); + } + if ( $routed['routed'] ) { + tmdo_live_assert_true( false === stripos( $routed['sql'], 'GROUP BY' ), 'routed query adds no GROUP BY: ' . $routed['sql'] ); + } +} + +TMDO_Live::test( + 'Q04 OR across registered keys returns the native posts', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( + 'relation' => 'OR', + array( 'key' => 'hp_featured', 'value' => '1' ), + array( 'key' => 'hp_verified', 'value' => '1' ), + ), + ), + array_values( $l ) + ); + } +); + +TMDO_Live::test( + 'Q16 OR mixing a registered and an unregistered key returns the native posts', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( + 'relation' => 'OR', + array( 'key' => 'hp_price', 'value' => 1000, 'compare' => '<', 'type' => 'NUMERIC' ), + array( 'key' => 'tmdo_live_unregistered', 'value' => '0' ), + ), + ), + array_values( $l ) + ); + } +); + +TMDO_Live::test( + 'Q14 untyped comparison on a numeric column keeps string semantics', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( array( 'key' => 'hp_price', 'value' => '9000', 'compare' => '>' ) ), + ), + array_values( $l ) + ); + } +); + +TMDO_Live::test( + 'Q15 orderby on a named clause keeps the native order, routed', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( + 'rate' => array( 'key' => 'hp_hourly_rate', 'value' => 50, 'compare' => '<', 'type' => 'NUMERIC' ), + 'price' => array( 'key' => 'hp_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ), + ), + 'orderby' => 'price', + 'order' => 'ASC', + ), + array_values( $l ), + true + ); + } +); + +TMDO_Live::test( + 'Q08 several post types return the native posts', + static function () { + $l = tmdo_live_router_listings(); + TMDO_Live_Fixtures::field( 'post', 'hp_budget', 'hp_request' ); + $r = tmdo_live_router_posts( 'hp_request', array( 'R1' => array( 'hp_budget' => '90000' ) ) ); + update_post_meta( $l['L2'], 'hp_budget', '85000' ); + tmdo_live_router_assert_native( + array( + 'post_type' => array( 'hp_listing', 'hp_request' ), + 'meta_query' => array( array( 'key' => 'hp_budget', 'value' => 80000, 'compare' => '>=', 'type' => 'NUMERIC' ) ), + ), + array_merge( array_values( $l ), array_values( $r ) ) + ); + } +); + +TMDO_Live::test( + 'typed AND on registered keys is routed and returns the native posts', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( + array( 'key' => 'hp_price', 'value' => array( 9000, 15000 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ), + array( 'key' => 'hp_verified', 'value' => '1' ), + ), + ), + array_values( $l ), + true + ); + } +); + +TMDO_Live::test( + 'Q06 LIKE matches substrings and escapes wildcards, routed', + static function () { + TMDO_Live_Fixtures::field( 'post', '_sku', 'product' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + $p = tmdo_live_router_posts( + 'product', + array( + 'P1' => array( '_sku' => 'tmdo-live-B1-1' ), + 'P2' => array( '_sku' => 'tmdo-live-B1-10' ), + 'P3' => array( '_sku' => 'tmdo-live-B2-1' ), + ) + ); + foreach ( array( 'B1-1', 'B1_1' ) as $needle ) { + tmdo_live_router_assert_native( + array( + 'post_type' => 'product', + 'meta_query' => array( array( 'key' => '_sku', 'value' => $needle, 'compare' => 'LIKE' ) ), + ), + array_values( $p ), + true + ); + } + } +); + +TMDO_Live::test( + 'Q05 keys of two groups are both joined, routed', + static function () { + $l = tmdo_live_router_listings(); + TMDO_Live_Fixtures::field( 'post', '_thumbnail_id', 'hp_listing' ); + update_post_meta( $l['L1'], '_thumbnail_id', '123' ); + update_post_meta( $l['L3'], '_thumbnail_id', '124' ); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( + array( 'key' => 'hp_hourly_rate', 'value' => 42, 'compare' => '>=', 'type' => 'NUMERIC' ), + array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ), + ), + ), + array_values( $l ), + true + ); + } +); + +TMDO_Live::test( + 'NOT EXISTS with explicit statuses and date order keeps posts without a flat row', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'post_status' => array( 'draft' ), + 'orderby' => 'date', + 'order' => 'DESC', + 'meta_query' => array( array( 'key' => 'hp_featured', 'compare' => 'NOT EXISTS' ) ), + ), + array_values( $l ), + true, + false + ); + } +); + +TMDO_Live::test( + 'HivePress sort shape (NOT EXISTS OR EXISTS, named clause) keeps the native order, routed', + static function () { + $l = tmdo_live_router_listings(); + foreach ( array( 'ASC', 'DESC' ) as $order ) { + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_query' => array( + array( + 'relation' => 'OR', + 'price__order' => array( 'key' => 'hp_price', 'type' => 'DECIMAL', 'compare' => 'NOT EXISTS' ), + array( 'key' => 'hp_price', 'type' => 'DECIMAL', 'compare' => 'EXISTS' ), + ), + ), + 'orderby' => 'price__order', + 'order' => $order, + ), + array_values( $l ), + true + ); + } + } +); + +TMDO_Live::test( + 'Q07 meta_key + meta_value_num keeps the native posts and order, routed', + static function () { + $l = tmdo_live_router_listings(); + tmdo_live_router_assert_native( + array( + 'post_type' => 'hp_listing', + 'meta_key' => 'hp_price', + 'orderby' => 'meta_value_num', + 'order' => 'DESC', + ), + array_values( $l ), + true + ); + } +); + +TMDO_Live::test( + 'a routed full-row search keeps the split query (IDs first) and native found_posts', + static function () { + global $wpdb; + $l = tmdo_live_router_listings(); + $args = array( + 'post_type' => 'hp_listing', + 'post__in' => array_values( $l ), + 'post_status' => 'any', + 'posts_per_page' => 2, + 'orderby' => 'ID', + 'order' => 'ASC', + 'meta_query' => array( array( 'key' => 'hp_price', 'value' => 1000, 'compare' => '>=', 'type' => 'NUMERIC' ) ), + ); + $native = new WP_Query( $args ); + $router = new TMDO_Post_Query_Router(); + $router->register_hooks(); + try { + $routed = new WP_Query( $args ); + } finally { + tmdo_live_router_unhook( $router ); + } + + tmdo_live_assert_same( wp_list_pluck( $native->posts, 'ID' ), wp_list_pluck( $routed->posts, 'ID' ), 'page of posts' ); + tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' ); + tmdo_live_assert_true( false !== strpos( $routed->request, 'wpdoflat_' ), 'routed: ' . $routed->request ); + tmdo_live_assert_true( 1 === preg_match( '/^\s*SELECT\s+' . preg_quote( $wpdb->posts, '/' ) . '\.ID\s/', $routed->request ), 'IDs first: ' . $routed->request ); + } +); + +TMDO_Live::test( + 'a filtered search ordered by a flat column drives from the flat table and matches native', + static function () { + foreach ( array( 'hp_price', 'hp_hourly_rate' ) as $key ) { + TMDO_Live_Fixtures::field( 'post', $key, 'hp_listing' ); + } + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + // A price band no seeded listing uses keeps the result to the fixtures. + tmdo_live_router_posts( + 'hp_listing', + array( + 'A' => array( 'hp_price' => '9876501', 'hp_hourly_rate' => '12' ), + 'B' => array( 'hp_price' => '9876502', 'hp_hourly_rate' => '15' ), + 'C' => array( 'hp_price' => '9876503' ), + ) + ); + $args = array( + 'post_type' => 'hp_listing', + 'post_status' => 'any', + 'posts_per_page' => 10, + 'fields' => 'ids', + 'suppress_filters' => false, + 'meta_query' => array( array( 'key' => 'hp_price', 'value' => array( 9876500, 9876510 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ), + 'meta_key' => 'hp_price', + 'orderby' => 'meta_value_num', + 'order' => 'DESC', + ); + $native = new WP_Query( $args ); + $router = new TMDO_Post_Query_Router(); + $router->register_hooks(); + try { + $routed = new WP_Query( $args ); + } finally { + tmdo_live_router_unhook( $router ); + } + + tmdo_live_assert_same( array_map( 'intval', $native->posts ), array_map( 'intval', $routed->posts ), 'ids' ); + tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' ); + tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request ); + } +); diff --git a/tests/live/suites/serialized.php b/tests/live/suites/serialized.php new file mode 100644 index 0000000..ea98d25 --- /dev/null +++ b/tests/live/suites/serialized.php @@ -0,0 +1,59 @@ + 'dual_write' ) ); + $uid = TMDO_Live_Fixtures::user(); + $prefs = array( 'core' => array( 'welcomeGuide' => false ) ); + $loc = array( 'ip' => '192.0.2.0' ); + + update_user_meta( $uid, 'wp_persisted_preferences', $prefs ); + update_user_meta( $uid, 'community-events-location', $loc ); + + tmdo_live_assert_same( serialize( $prefs ), TMDO_Live_Fixtures::flat( 'user', $uid, 'wp_persisted_preferences' ), 'flat wp_persisted_preferences' ); + tmdo_live_assert_same( serialize( $loc ), TMDO_Live_Fixtures::flat( 'user', $uid, 'community-events-location' ), 'flat community-events-location' ); + } +); + +TMDO_Live::test( + 'backfill: native array meta is flattened serialized, not "Array"', + static function () { + TMDO_Live_Fixtures::field( 'user', 'community-events-location' ); + TMDO_Live_Fixtures::use_modes( array( 'user' => 'disabled' ) ); + $uid = TMDO_Live_Fixtures::user(); + $loc = array( 'ip' => '198.51.100.0' ); + update_user_meta( $uid, 'community-events-location', $loc ); + tmdo_live_assert_same( array( serialize( $loc ) ), TMDO_Live_Fixtures::native( 'user', $uid, 'community-events-location' ), 'native precondition' ); + + $field = TMDO_Entity_Registry::get_field( 'user', 'community-events-location' ); + TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $field['group'] ); + TMDO_Entity_Migration_Engine::migrate_group( 'user', $field['group'], array( 'sleep_ms' => 0, 'resume' => false ) ); + + tmdo_live_assert_same( serialize( $loc ), TMDO_Live_Fixtures::flat( 'user', $uid, 'community-events-location' ), 'flat after backfill' ); + } +); + +TMDO_Live::test( + 'read path: from_db returns the array, not a serialized string', + static function () { + $field = TMDO_Live_Fixtures::field( 'user', 'wp_persisted_preferences' ); + $prefs = array( 'core/edit-post' => array( 'panels' => array( 'a', 'b' ) ) ); + + $stored = TMDO_Type_Caster::to_db( $prefs, $field ); + + tmdo_live_assert_same( $prefs, TMDO_Type_Caster::from_db( $stored, $field ), 'round trip' ); + } +); diff --git a/tests/live/suites/shadow_write.php b/tests/live/suites/shadow_write.php new file mode 100644 index 0000000..4327a01 --- /dev/null +++ b/tests/live/suites/shadow_write.php @@ -0,0 +1,96 @@ + 'dual_write' ) ); + $uid = TMDO_Live_Fixtures::user(); + update_user_meta( $uid, 'first_name', 'Before' ); + + $ret = update_user_meta( $uid, 'first_name', 'After' ); + + tmdo_live_assert_true( false !== $ret, 'update_user_meta returned false' ); + tmdo_live_assert_same( array( 'After' ), TMDO_Live_Fixtures::native( 'user', $uid, 'first_name' ), 'native first_name' ); + tmdo_live_assert_same( 'After', TMDO_Live_Fixtures::flat( 'user', $uid, 'first_name' ), 'flat first_name' ); + } +); + +TMDO_Live::test( + 'shadow_read: user first_name update reaches native', + static function () { + TMDO_Live_Fixtures::field( 'user', 'first_name' ); + TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) ); + $uid = TMDO_Live_Fixtures::user(); + update_user_meta( $uid, 'first_name', 'Before' ); + TMDO_Live_Fixtures::use_modes( array( 'user' => 'shadow_read' ) ); + + $ret = update_user_meta( $uid, 'first_name', 'After' ); + + tmdo_live_assert_true( false !== $ret, 'update_user_meta returned false' ); + tmdo_live_assert_same( array( 'After' ), TMDO_Live_Fixtures::native( 'user', $uid, 'first_name' ), 'native first_name' ); + } +); + +TMDO_Live::test( + 'shadow_read → dual_write: value written in shadow_read survives demotion', + static function () { + TMDO_Live_Fixtures::field( 'user', 'last_name' ); + TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) ); + $uid = TMDO_Live_Fixtures::user(); + update_user_meta( $uid, 'last_name', 'Before' ); + TMDO_Live_Fixtures::use_modes( array( 'user' => 'shadow_read' ) ); + update_user_meta( $uid, 'last_name', 'After' ); + + TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) ); + wp_cache_delete( $uid, 'user_meta' ); + + tmdo_live_assert_same( 'After', get_user_meta( $uid, 'last_name', true ), 'last_name after demotion' ); + } +); + +TMDO_Live::test( + 'shadow_read: HivePress term hp_icon update reaches native', + static function () { + TMDO_Live_Fixtures::field( 'term', 'hp_icon' ); + TMDO_Live_Fixtures::use_modes( array( 'term' => 'dual_write' ) ); + $tid = TMDO_Live_Fixtures::term( 'hp_listing_category' ); + update_term_meta( $tid, 'hp_icon', 'star' ); + TMDO_Live_Fixtures::use_modes( array( 'term' => 'shadow_read' ) ); + + $ret = update_term_meta( $tid, 'hp_icon', 'heart' ); + + tmdo_live_assert_true( false !== $ret, 'update_term_meta returned false' ); + tmdo_live_assert_same( array( 'heart' ), TMDO_Live_Fixtures::native( 'term', $tid, 'hp_icon' ), 'native hp_icon' ); + } +); + +TMDO_Live::test( + 'shadow_read: HivePress listing hp_rating_count (new int key) reaches native', + static function () { + TMDO_Live_Fixtures::field( 'post', 'hp_rating_count', 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'dual_write' ) ); + $lid = TMDO_Live_Fixtures::post( 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + + // HivePress writes the recomputed count as an int; the flat read cast it + // to the same int, so the old code skipped the native write. + $ret = update_post_meta( $lid, 'hp_rating_count', 1 ); + + tmdo_live_assert_true( false !== $ret, 'update_post_meta returned false' ); + tmdo_live_assert_same( array( '1' ), TMDO_Live_Fixtures::native( 'post', $lid, 'hp_rating_count' ), 'native hp_rating_count' ); + tmdo_live_assert_same( '1', TMDO_Live_Fixtures::flat( 'post', $lid, 'hp_rating_count', 'hp_listing' ), 'flat hp_rating_count' ); + } +); diff --git a/tests/live/suites/zero_side_effect.php b/tests/live/suites/zero_side_effect.php new file mode 100644 index 0000000..dbbca04 --- /dev/null +++ b/tests/live/suites/zero_side_effect.php @@ -0,0 +1,46 @@ + $id, 'post_title' => 'tmdo-live-updated' ) ); + wp_delete_post( $id, true ); + } finally { + remove_filter( 'query', $spy ); + } + + tmdo_live_assert_same( array(), $touched, 'queries on missing zone tables' ); + } +); diff --git a/tests/live/tools/checker.php b/tests/live/tools/checker.php new file mode 100644 index 0000000..b7df022 --- /dev/null +++ b/tests/live/tools/checker.php @@ -0,0 +1,46 @@ + 'post_id', 'user' => 'user_id', 'term' => 'term_id', 'comment' => 'comment_id' ); +$metas = array( 'post' => $wpdb->postmeta, 'user' => $wpdb->usermeta, 'term' => $wpdb->termmeta, 'comment' => $wpdb->commentmeta ); +$tot = array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ); + +printf( "%-8s %-24s %-28s %8s %8s %6s %6s %6s\n", 'entity', 'group', 'key', 'native', 'flat', 'miss', 'diff', 'extra' ); +foreach ( $ids as $e => $idcol ) { + if ( $only && $only !== $e ) { continue; } + $mt = $metas[ $e ]; + $mid = ( 'user' === $e ) ? 'umeta_id' : 'meta_id'; + foreach ( TMDO_Entity_Registry::get_groups_for_type( $e ) as $g ) { + $table = $wpdb->prefix . 'wpdo_' . $e . '_' . $g; + if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) { continue; } + $scope = ( 'post' === $e ) ? TMDO_Entity_Registry::get_group_post_types( $e, $g ) : array(); + $scope_sql = $scope ? " AND m.post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_type IN ('" . implode( "','", array_map( 'esc_sql', $scope ) ) . "'))" : ''; + foreach ( TMDO_Entity_Registry::get_group_keys( $e, $g ) as $key ) { + $col = preg_replace( '/[^a-zA-Z0-9_]/', '', $key ); + // Latest native value per entity. + $nat = $wpdb->prepare( "SELECT m.$idcol AS eid, m.meta_value AS v FROM $mt m JOIN (SELECT $idcol, MAX($mid) mid FROM $mt WHERE meta_key=%s GROUP BY $idcol) x ON x.mid=m.$mid WHERE 1=1 $scope_sql", $key ); + $n_native = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ($nat) n" ); + $n_flat = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table WHERE `$col` IS NOT NULL" ); + $miss = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ($nat) n LEFT JOIN $table f ON f.$idcol=n.eid WHERE f.$idcol IS NULL OR (f.`$col` IS NULL AND n.v <> '')" ); + // Value diff: numeric-aware loose compare. + $diff = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ($nat) n JOIN $table f ON f.$idcol=n.eid WHERE f.`$col` IS NOT NULL AND NOT ( + CAST(f.`$col` AS CHAR) = n.v + OR (n.v REGEXP '^-?[0-9]+(\\\\.[0-9]+)?$' AND CAST(f.`$col` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6))) + OR (n.v='' AND CAST(f.`$col` AS CHAR) IN ('','0')) + )" ); + $extra = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table f WHERE f.`$col` IS NOT NULL AND NOT EXISTS (SELECT 1 FROM $mt m WHERE m.$idcol=f.$idcol AND m.meta_key='" . esc_sql( $key ) . "')" ); + if ( $n_native || $n_flat ) { + printf( "%-8s %-24s %-28s %8d %8d %6d %6d %6d\n", $e, $g, $key, $n_native, $n_flat, $miss, $diff, $extra ); + } + $tot['miss'] += $miss; + $tot['diff'] += $diff; + $tot['extra'] += $extra; + } + } +} +printf( "TOTAL miss=%d diff=%d extra=%d\n", $tot['miss'], $tot['diff'], $tot['extra'] ); diff --git a/tests/live/tools/clone.sql b/tests/live/tools/clone.sql new file mode 100644 index 0000000..15fbfc1 --- /dev/null +++ b/tests/live/tools/clone.sql @@ -0,0 +1,12 @@ +-- Clone original hp_listing rows (ID < 20000) into copy k with ID offset k*20000. +-- Bench-only: clones hp_listing rows into offset IDs. Never run on a production site. +-- Usage: sed "s/__K__//" clone.sql | wp db query +SET @k := __K__; SET @off := @k * 20000; +INSERT INTO wp_posts (ID, post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, post_status, comment_status, ping_status, post_password, post_name, to_ping, pinged, post_modified, post_modified_gmt, post_content_filtered, post_parent, guid, menu_order, post_type, post_mime_type, comment_count) +SELECT ID+@off, post_author, d, d, post_content, CONCAT(post_title,' c',@k), post_excerpt, post_status, comment_status, ping_status, post_password, CONCAT(post_name,'-c',@k), to_ping, pinged, d, d, post_content_filtered, post_parent, CONCAT(guid,'&c=',@k), menu_order, post_type, post_mime_type, comment_count +FROM (SELECT *, DATE_SUB('2026-09-26 12:00:00', INTERVAL FLOOR(RAND()*63072000) SECOND) d FROM wp_posts WHERE post_type='hp_listing' AND ID < 20000) s; +INSERT INTO wp_postmeta (post_id, meta_key, meta_value) +SELECT m.post_id+@off, m.meta_key, CASE m.meta_key WHEN 'hp_price' THEN FLOOR(100+RAND()*49900) WHEN 'hp_hourly_rate' THEN FLOOR(10+RAND()*290) ELSE m.meta_value END +FROM wp_postmeta m JOIN wp_posts p ON p.ID=m.post_id WHERE p.post_type='hp_listing' AND p.ID < 20000 AND m.meta_key NOT LIKE '\_transient%'; +INSERT INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) +SELECT r.object_id+@off, r.term_taxonomy_id, r.term_order FROM wp_term_relationships r JOIN wp_posts p ON p.ID=r.object_id WHERE p.post_type='hp_listing' AND p.ID < 20000; diff --git a/tests/live/tools/fe.sh b/tests/live/tools/fe.sh new file mode 100644 index 0000000..66e3eb0 --- /dev/null +++ b/tests/live/tools/fe.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# usage: fe.sh