diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index defb69a..1d11b45 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.1.1 + * Version: 1.2.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.1.1' ); +define( 'TMDO_VERSION', '1.2.0' ); define( 'TMDO_DB_VERSION', '2.1.0' ); define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_URL', plugin_dir_url( __FILE__ ) ); @@ -268,6 +268,7 @@ add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'b require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php'; require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php'; require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php'; +require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php'; diff --git a/CHANGELOG.md b/CHANGELOG.md index 472e8d2..dc94987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,80 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [1.2.0] — 2026-09-27 — 漂移偵測、範圍收斂、CLI 與診斷(hardening 2.2) + +**性質**:功能 + 資料正確性修正。無 schema 變更。延續 ADR-004 的搜尋索引模式;搭配 +HivePress AddOn 0.3.0、WooCommerce AddOn 0.2.0。 + +### Added + +- **`wp tmdo drift-check [--group] [--sample=] [--format]`**(`TMDO_Drift_Check`): + 逐 key 比對扁平表與原生 meta 的值(第一筆原生 row → 安全 unserialize → + `Type_Caster::to_db`),回報 miss / diff / extra。序列化 vs JSON、`50` vs `50.000000` + 不算漂移;超過 255 字元存 NULL 的 text 不算 miss。結果寫入 `wpdo_drift_status`。(PR-070) +- **`drift-check --fix`**:刪除已刪實體與非本 group post type 的扁平列,其餘漂移列經 + `TMDO_Entity_Migration_Engine::migrate_entity()` 從原生重建,清除列快取後再檢查一次。 + 不寫原生;aeav_only(扁平才是真實資料)一律拒絕。(PR-071、PR-073b) +- **每日抽樣**:`wpdo_daily_health_check` 對 dual_write 以上、非 aeav_only 的 entity 每個 + group 抽 500 筆,結果取代既有紀錄。(PR-073) +- **Hook Bus 關閉期間的寫入補回**:entity 從 disabled 進入 dual_write / shadow_read,或 + Hook Bus flag 由關轉開時,60 秒後以單次 WP-Cron `tmdo_drift_resync` 執行全量 + `drift-check --fix`(不做 `post_modified_gmt` 增量:WooCommerce 直接 SQL 改庫存不會更新 + post_modified)。停用 / 解除安裝時清除排程。(PR-074) +- **`wp tmdo bridge-mode `**:經 `TMDO_Mode_Manager::set()`,受 + Promotion Guard 約束;被拒時 exit 1、模式不變。(PR-069) +- **`wp tmdo member-backfill --all`**:回填所有已註冊的 user group。(PR-068) +- **`wp tmdo cleanup-hp-transients --options [--dry-run|--confirm] [--batch=]`**: + 刪除 HivePress AddOn 0.3.0 之前寫進 wp_options 的 `_transient_wpdo_hp_pm_*`(含 timeout), + 分批刪除(預設 5000),AddOn 未啟用也可執行。dev36 有 2,314,750 列。(PR-080) + +### Changed + +- **Post query router 避開漂移中的 group**:上次 drift-check 有 miss / diff / extra 的 + group 不路由,整個查詢維持原生(全有或全無);從未檢查的 group 照常路由;aeav_only + 不受影響。(PR-072、PR-073b) +- **純排序搜尋 flat-first**:沒有路由篩選的 HivePress `_sort` 搜尋,在該 post type 每篇都有 + 扁平列時改由扁平表驅動(`STRAIGHT_JOIN` + INNER JOIN);Router 在 `wp_insert_post` + 替新文章建空列,覆蓋率以 anti-join 檢查並快取 15 分鐘(隨 post 模式變更失效)。 + flat-first 且扁平表是唯一 JOIN 時拿掉 WordPress 為 tax query 加的 `GROUP BY posts.ID`。 + dev36 `rating__desc`:原生約 3.3 s、1.1.0 約 1.5–1.9 s、現在約 0.51–0.85 s。(PR-079) +- **Auto-promoter** 的第三個條件改為 `TMDO_Drift_Check::clean_since()`:2 天內每個 group 都 + 檢查過(抽樣或全量)且沒有漂移;沒有紀錄就不升級。(PR-075) +- **shadow_read diff 節流**:每個 entity type + key 每小時最多記 20 列(疊加既有的 + 每物件 5 分鐘視窗)。(PR-075) +- **回填 group 改讀 registry**:admin「全部回填」、`post-migrate-group`、`member-backfill`、 + `member-audit` 取所有已註冊 group(排除已還原的孤兒 group),不再是寫死清單;含 json + 欄位的 group(attachment、nav_menu_item)逐列經 migration engine。(PR-067、PR-068) +- **不再註冊 `post/wc_product`**:WooCommerce 直接讀 product meta、以 SQL 寫庫存,扁平副本 + 必然漂移(ADR-004 決策 2)。既有的 `wp_wpdo_post_wc_product` 表保留,以 + `wp tmdo bridge-restore-native post --group=wc_product --retire` 封存;解除安裝仍會刪除。(PR-077) +- `member-sso-sync`、`member-force-logout` 標示為 deprecated(`sso` group 已不註冊)。(PR-069) + +### Fixed + +- Entity 回填替沒有原生 row 的 key 寫入 DDL 預設值(`session_count` / `account_flags` = 0), + 每個 user 都被捏造出 0;現在寫 NULL,required NOT NULL 欄位保留預設值。(PR-066) +- `member-cutover` 在轉換被拒時 fatal(把 `WP_Error` 當陣列)。(PR-068) +- `doctor` 依 registry 檢查 entity 表(不再因從未註冊的 `sso` 表而每站失敗),並顯示 + Promotion Guard 違規與 drift 狀態;`term-comment-diagnose` 依各 filter 的 `is_enabled()` + 顯示狀態;`bridge-status` 只計有值的扁平列、eav_ids 限定 group 的 post type + (dev36 wp_core 15306% → 100%)。(PR-076) +- 2.1 稽核:公開 REST route 註明公開理由;admin 23 處三元 echo 依情境 `esc_attr` / + `esc_html`;migration engine 與 health cron 的例外改記 `TMDO_Logger`。(PR-078) + +### Docs + +- ADR-004「設計(Release 2.2)」、`CONTEXT.md` Entity Bridge 的漂移段落。(PR-086) + +### 升級後 + +- HivePress 站:部署 AddOn 0.3.0 後執行 `wp tmdo cleanup-hp-transients --options --confirm`。 +- 曾註冊 wc_product 的站:`wp tmdo bridge-restore-native post --group=wc_product --retire`。 +- 已進入 dual_write 以上的站:`wp tmdo drift-check ` 建立第一筆紀錄(未檢查的 + group 照常路由)。 + +--- + ## [1.1.1] — 2026-09-26 — Security:公開 REST 不再回傳私有欄位 **性質**:安全修正(Critical)。1.0.x 起即存在;1.1.0 zip 已撤回(`dist/withdrawn/`)。 diff --git a/CONTEXT.md b/CONTEXT.md index 567d15a..9deac33 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -158,6 +158,13 @@ semantics, LIKE as `%esc_like%`. Meta ordering (the HivePress `_sort` NOT EXISTS shape, a named clause, `meta_key` + `meta_value_num`) is routed only as the sole `orderby` on a column that orders like native. +**Drift** (v1.2.0). `TMDO_Drift_Check` compares flat values with native meta per key +(miss / diff / extra) and records the outcome per group in `wpdo_drift_status`; +`--fix` rebuilds flat rows from native. The router routes no clause of a group recorded +as drifting (never checked → routes). A daily sample, and a full fix 60 s after an +entity leaves `disabled` or the Hook Bus is switched back on (`tmdo_drift_resync`), keep +the record current. None of this applies in `aeav_only`, where flat is the source of truth. + --- ## Migration Phase (Strategy pattern) diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php index f28da03..288aaff 100644 --- a/admin/class-tmdo-admin.php +++ b/admin/class-tmdo-admin.php @@ -556,11 +556,11 @@ class TMDO_Admin { exit; } - // v2.10.0: Post Migration Wizard — backfill all 7 groups. + // v2.10.0: Post Migration Wizard — backfill every registered post group. if ( isset( $_POST['wpdo_post_backfill_all'] ) && check_admin_referer( 'wpdo_post_backfill_all' ) && class_exists( 'TMDO_Post_Migration' ) ) { $total_migrated = 0; $errors = array(); - foreach ( array( 'wp_core', 'attachment', 'wc_product', 'hp_listing_core', 'hp_request_core', 'hp_vendor_core', 'nav_menu_item' ) as $group ) { + foreach ( TMDO_Post_Migration::backfillable_groups() as $group ) { try { $result = TMDO_Post_Migration::backfill_group( $group ); $total_migrated += (int) $result['migrated']; @@ -846,8 +846,8 @@ class TMDO_Admin { > + class="nav-tab " + aria-current=""> External' : 'Built-in'; ?> + + External + + Built-in + Flush Support @@ -1726,13 +1730,13 @@ class TMDO_Admin { ↑ @@ -2037,7 +2041,7 @@ class TMDO_Admin { -
+

@@ -2071,7 +2075,7 @@ class TMDO_Admin {
-
+

diff --git a/admin/class-tmdo-dashboard-widget.php b/admin/class-tmdo-dashboard-widget.php index 588434c..357c8e1 100644 --- a/admin/class-tmdo-dashboard-widget.php +++ b/admin/class-tmdo-dashboard-widget.php @@ -246,7 +246,7 @@ class TMDO_Dashboard_Widget { '%2$s' ); ?> -

+

📦 0 ) { diff --git a/admin/templates/comment-stress-test.php b/admin/templates/comment-stress-test.php index e27252d..643965d 100644 --- a/admin/templates/comment-stress-test.php +++ b/admin/templates/comment-stress-test.php @@ -211,7 +211,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment

-
+

@@ -249,7 +249,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
-
+

diff --git a/admin/templates/migration-wizard.php b/admin/templates/migration-wizard.php index 23bb667..af7e45a 100644 --- a/admin/templates/migration-wizard.php +++ b/admin/templates/migration-wizard.php @@ -99,7 +99,7 @@ $failed = 'failed' === $state;
> + >
@@ -170,7 +170,7 @@ $failed = 'failed' === $state;
> + >

@@ -210,7 +210,7 @@ $failed = 'failed' === $state;
> + >

✅

diff --git a/admin/templates/post-migration-wizard.php b/admin/templates/post-migration-wizard.php index 4076d56..f5cf43a 100644 --- a/admin/templates/post-migration-wizard.php +++ b/admin/templates/post-migration-wizard.php @@ -218,7 +218,7 @@ $wpdo_format_msg = static function ( string $code ): string {
- diff --git a/admin/templates/post-stress-test.php b/admin/templates/post-stress-test.php index b81c850..51d4eaf 100644 --- a/admin/templates/post-stress-test.php +++ b/admin/templates/post-stress-test.php @@ -161,22 +161,22 @@ $post_type_options = array( - + disabled wp_postmeta 5 rows / flat 0 → 1:5(無優化) wp_postmeta 5 / flat 0 → 1:5(無優化) - + dual_write wp_postmeta 5 / flat 0 → 1:5 wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減) - + shadow_read wp_postmeta 5 / flat 0 → 1:5 wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫) - + aeav_only wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️ wp_postmeta 0 / flat 1 → 0:1(完全優化) ✅ @@ -291,7 +291,7 @@ $post_type_options = array(
-
+

@@ -329,7 +329,7 @@ $post_type_options = array(
-
+

diff --git a/admin/templates/term-stress-test.php b/admin/templates/term-stress-test.php index 09ba3e9..92a0ca6 100644 --- a/admin/templates/term-stress-test.php +++ b/admin/templates/term-stress-test.php @@ -207,7 +207,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
-
+

@@ -245,7 +245,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
-
+

diff --git a/cli/class-tmdo-cli-member.php b/cli/class-tmdo-cli-member.php index 06c296c..a3641b6 100644 --- a/cli/class-tmdo-cli-member.php +++ b/cli/class-tmdo-cli-member.php @@ -9,8 +9,8 @@ * wp wpdo member-shadow-report — Recent entity_type=user diff records. * wp wpdo member-cutover — Switch user mode to aeav_only. * wp wpdo member-points-check — Show balance + recent ledger for a user. - * wp wpdo member-sso-sync — Sync Hub membership claims to flat table. - * wp wpdo member-force-logout — Set token_expires_at to past → re-auth. + * wp wpdo member-sso-sync — (deprecated) Sync Hub membership claims to flat table. + * wp wpdo member-force-logout — (deprecated) Set token_expires_at to past → re-auth. * * @package WP_Data_Optimizer * @since 2.5.5 @@ -35,24 +35,6 @@ if ( ! class_exists( 'WP_CLI' ) ) { */ class TMDO_CLI_Member { - /** - * Valid group names managed by this CLI. - * - * V2.7.0: Extended with core_profile/social/commerce/hp_user to absorb - * the legacy WP/WC/HP wp_usermeta keys. - */ - private const VALID_GROUPS = array( - 'membership', - 'activity', - 'profile', - 'sso', - 'core_profile', - 'social', - 'commerce', - 'hp_user', - 'admin_prefs', - ); - // ── member-audit ───────────────────────────────────────────────────────── /** @@ -73,7 +55,7 @@ class TMDO_CLI_Member { WP_CLI::log( '' ); $items = array(); - foreach ( self::VALID_GROUPS as $group ) { + foreach ( TMDO_Entity_Registry::get_groups_for_type( 'user' ) as $group ) { $table = $wpdb->prefix . 'wpdo_user_' . $group; $exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) @@ -111,7 +93,7 @@ class TMDO_CLI_Member { // ── member-backfill ─────────────────────────────────────────────────────── /** - * Backfill wp_usermeta rows into the specified user flat-table group. + * Backfill wp_usermeta rows into one user flat-table group, or all of them. * * Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based, * 500 rows/batch, checkpoint stored in wpdo_migration_status. @@ -119,7 +101,10 @@ class TMDO_CLI_Member { * ## OPTIONS * * [--group=] - * : Which group to backfill: membership, activity, profile, sso. Default: membership. + * : Registered user group to backfill (e.g. membership, activity, core_profile). Default: membership. + * + * [--all] + * : Backfill every registered user group (restored orphan groups excluded). * * [--batch-size=] * : Rows per batch (default 500, max 2000). @@ -134,21 +119,38 @@ class TMDO_CLI_Member { * * wp wpdo member-backfill --group=membership --dry-run * wp wpdo member-backfill --group=membership - * wp wpdo member-backfill --group=sso --reset + * wp wpdo member-backfill --all * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function member_backfill( $args, $assoc_args ): void { - $group = (string) ( $assoc_args['group'] ?? 'membership' ); $batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) ); $dry_run = isset( $assoc_args['dry-run'] ); $reset = isset( $assoc_args['reset'] ); + $valid = TMDO_Entity_Migration_Engine::backfillable_groups( 'user' ); - if ( ! in_array( $group, self::VALID_GROUPS, true ) ) { - WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) ); + if ( isset( $assoc_args['all'] ) ) { + if ( isset( $assoc_args['group'] ) ) { + WP_CLI::error( 'Use either --group or --all, not both.' ); + } + $groups = $valid; + } else { + $groups = array( (string) ( $assoc_args['group'] ?? 'membership' ) ); + if ( ! in_array( $groups[0], $valid, true ) ) { + WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid ) ); + } } + foreach ( $groups as $group ) { + $this->backfill_group( $group, $batch_size, $dry_run, $reset ); + } + } + + /** + * Backfill one user group and report the result. + */ + private function backfill_group( string $group, int $batch_size, bool $dry_run, bool $reset ): void { if ( $reset ) { TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group ); WP_CLI::log( "Checkpoint cleared for user/{$group}." ); @@ -298,8 +300,8 @@ class TMDO_CLI_Member { $result = TMDO_Mode_Manager::set( 'user', 'aeav_only' ); - if ( isset( $result['error'] ) ) { - WP_CLI::error( 'Cutover failed: ' . $result['error'] ); + if ( is_wp_error( $result ) ) { + WP_CLI::error( 'Cutover failed: ' . $result->get_error_message() ); } WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' ); @@ -356,6 +358,9 @@ class TMDO_CLI_Member { /** * Sync Hub membership claims into the user flat table. * + * Deprecated since 1.2.0: the user `sso` group is no longer registered, so + * its flat table is never created on new installs. Will be removed. + * * Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token * from usermeta and writes them into the sso group flat table. * Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing @@ -381,6 +386,7 @@ class TMDO_CLI_Member { * @param array $assoc_args Named arguments. */ public function member_sso_sync( $args, $assoc_args ): void { + WP_CLI::warning( 'member-sso-sync is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' ); global $wpdb; $user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0; @@ -466,6 +472,9 @@ class TMDO_CLI_Member { /** * Force re-authentication by setting token_expires_at to a past datetime. * + * Deprecated since 1.2.0: works on the user `sso` flat table, which is no + * longer registered. Will be removed. + * * The Spoke silent-refresh logic detects an expired token_expires_at and * redirects the user to re-authenticate against the Hub. If the Hub has * the account blocked, the user is logged out from all Spokes. @@ -490,6 +499,7 @@ class TMDO_CLI_Member { * @param array $assoc_args Named arguments. */ public function member_force_logout( $args, $assoc_args ): void { + WP_CLI::warning( 'member-force-logout is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' ); if ( ! isset( $assoc_args['confirm'] ) ) { WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' ); } diff --git a/cli/class-tmdo-cli-post.php b/cli/class-tmdo-cli-post.php index ac9382f..9dc287b 100644 --- a/cli/class-tmdo-cli-post.php +++ b/cli/class-tmdo-cli-post.php @@ -198,29 +198,20 @@ class TMDO_CLI_Post { /** * Backfill one post entity group from wp_postmeta into its flat table - * via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE). + * via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE). Groups with + * JSON-typed fields are backfilled row by row instead. * - * Idempotent — safe to re-run. Skips JSON-typed fields (handled by the - * row-by-row backfill phase in v2.9.4+). + * Idempotent — safe to re-run. * * ## OPTIONS * * --group= - * : Entity group name. Required. - * --- - * options: - * - wp_core - * - attachment - * - wc_product - * - hp_listing_core - * - hp_request_core - * - hp_vendor_core - * - nav_menu_item - * --- + * : Registered post entity group name (e.g. wp_core, attachment, + * hp_listing_core). Required. * * ## EXAMPLES * - * wp wpdo post-migrate-group --group=wc_product + * wp wpdo post-migrate-group --group=hp_listing_core * wp wpdo post-migrate-group --group=hp_listing_core * * @param array $args Positional arguments (unused). @@ -439,6 +430,10 @@ class TMDO_CLI_Post { * Safe to run with the filter enabled; the filter prevents new postmeta * writes from re-bloating the table. * + * With --options it removes instead the rows the filter wrote to wp_options + * (`_transient_wpdo_hp_pm_*`) before 1.2.0 / AddOn 0.3.0 kept them in the + * object cache; works without the AddOn active. + * * ## OPTIONS * * [--dry-run] @@ -447,10 +442,17 @@ class TMDO_CLI_Post { * [--confirm] * : Required to actually DELETE rows. * + * [--options] + * : Clean the AddOn's rows in wp_options instead of wp_postmeta. + * + * [--batch=] + * : Rows per DELETE with --options. Default 5000. + * * ## EXAMPLES * * wp wpdo cleanup-hp-transients --dry-run * wp wpdo cleanup-hp-transients --confirm + * wp wpdo cleanup-hp-transients --options --dry-run * * @param array $args Positional arguments (unused). * @param array $assoc_args Named arguments. @@ -470,6 +472,11 @@ class TMDO_CLI_Post { ); } + if ( isset( $assoc_args['options'] ) ) { + self::cleanup_hp_transient_options( $dry_run, max( 1, (int) ( $assoc_args['batch'] ?? 5000 ) ) ); + return; + } + if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) { WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' ); } @@ -503,6 +510,39 @@ class TMDO_CLI_Post { ); } + /** + * Count or delete the HivePress AddOn's transient rows in wp_options, in + * batches (dev36 held 1.15 M of them). + * + * @param bool $dry_run Count only. + * @param int $batch Rows per DELETE. + * @return void + */ + private static function cleanup_hp_transient_options( bool $dry_run, int $batch ): void { + global $wpdb; + // TMDO_Hivepress_Transient_Filter::TRANSLATED_NAMESPACE (AddOn), repeated + // here so the leftovers can be removed with the AddOn gone. + $ns = $wpdb->esc_like( 'wpdo_hp_pm_' ); + $where = $wpdb->prepare( 'option_name LIKE %s OR option_name LIKE %s', '\_transient\_' . $ns . '%', '\_transient\_timeout\_' . $ns . '%' ); + + if ( $dry_run ) { + $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->options} WHERE {$where}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + WP_CLI::success( sprintf( '[dry-run] %d HivePress transient row(s) in wp_options would be deleted. Re-run with --confirm.', $count ) ); + return; + } + + $deleted = 0; + do { + $rows = (int) $wpdb->query( "DELETE FROM {$wpdb->options} WHERE {$where} LIMIT {$batch}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $deleted += $rows; + } while ( $rows === $batch ); + + if ( class_exists( 'TMDO_Logger' ) ) { + TMDO_Logger::info( 'cleanup_hp_transient_options', array( 'deleted' => $deleted ) ); + } + WP_CLI::success( sprintf( 'Deleted %d HivePress transient row(s) from wp_options.', $deleted ) ); + } + // ── post-cutover-legacy (v2.9.5) ────────────────────────────────────────── /** diff --git a/cli/class-tmdo-cli-term-comment.php b/cli/class-tmdo-cli-term-comment.php index 96dea8a..1a35639 100644 --- a/cli/class-tmdo-cli-term-comment.php +++ b/cli/class-tmdo-cli-term-comment.php @@ -442,9 +442,9 @@ class TMDO_CLI_Term_Comment { WP_CLI::log( '' ); WP_CLI::log( '── Filter chain status ──' ); - self::print_filter_status( 'wpdo_term_comment_garbage_filter_enabled', 'Garbage filter (Phase 1)' ); - self::print_filter_status( 'wpdo_wc_term_count_filter_enabled', 'WC term count filter (Phase 3)' ); - self::print_filter_status( 'wpdo_term_comment_misc_bucket_enabled', 'Misc bucket (Phase 4)' ); + self::print_filter_status( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter (Phase 1)' ); + self::print_filter_status( 'TMDO_WC_Term_Count_Filter', 'WC term count filter (Phase 3)' ); + self::print_filter_status( 'TMDO_Term_Comment_Misc_Bucket', 'Misc bucket (Phase 4)' ); WP_CLI::log( '' ); WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) ); @@ -504,15 +504,19 @@ class TMDO_CLI_Term_Comment { } /** - * Print a filter's enabled/disabled status. + * Print a filter's enabled/disabled status, as the filter itself decides it + * (each has its own default). * - * @param string $option_key Option storing the toggle value. - * @param string $label Human-readable name. + * @param string $filter_class Filter class with a static is_enabled(). + * @param string $label Human-readable name. * @return void */ - private static function print_filter_status( string $option_key, string $label ): void { - $enabled = (bool) get_option( $option_key, '1' ); - WP_CLI::log( sprintf( '%-32s%s', $label . ':', $enabled ? '✓ enabled' : '✗ disabled' ) ); + private static function print_filter_status( string $filter_class, string $label ): void { + if ( ! class_exists( $filter_class ) ) { + WP_CLI::log( sprintf( '%-32s%s', $label . ':', '— not installed' ) ); + return; + } + WP_CLI::log( sprintf( '%-32s%s', $label . ':', $filter_class::is_enabled() ? '✓ enabled' : '✗ disabled' ) ); } // ── term-comment-shadow-report (v2.12.5 Phase 5) ────────────────────────── diff --git a/cli/class-tmdo-cli-v2.php b/cli/class-tmdo-cli-v2.php index fe6c07e..56dadc3 100644 --- a/cli/class-tmdo-cli-v2.php +++ b/cli/class-tmdo-cli-v2.php @@ -91,22 +91,29 @@ class TMDO_CLI_V2 { $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table ) ); - $flat_count = $flat_exists + // Flat rows holding at least one value (wp_core keeps an all-NULL row per post). + $managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name ); + $has_value = implode( ' OR ', array_map( static fn( $k ) => '`' . TMDO_Schema_Manager::sanitize_column_name( $k ) . '` IS NOT NULL', $managed_keys ) ); + $flat_count = $flat_exists && '' !== $has_value // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared - ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" ) + ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}` WHERE {$has_value}" ) : 0; // EAV source: count distinct entity IDs that have at least one managed key. - $managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name ); - $eav_table = $eav_tables[ $entity_type ] ?? ''; - $eav_count = 0; + $eav_table = $eav_tables[ $entity_type ] ?? ''; + $eav_count = 0; if ( '' !== $eav_table && ! empty( $managed_keys ) ) { $id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id"; $placeholders = implode( ', ', array_fill( 0, count( $managed_keys ), '%s' ) ); // $id_col comes from a hardcoded map; $eav_table is $wpdb->*meta (framework-managed); $placeholders is %s repeats only. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared - $sql = "SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$eav_table}` WHERE `meta_key` IN ({$placeholders})"; + $sql = "SELECT COUNT(DISTINCT m.`{$id_col}`) FROM `{$eav_table}` m WHERE m.`meta_key` IN ({$placeholders})"; + // A group of some post types only: other posts holding its keys are not its rows. + $post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( 'post', $group_name ) : array(); + if ( $post_types ) { + $sql .= " AND m.post_id IN ( SELECT ID FROM {$wpdb->posts} WHERE post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "') )"; + } $eav_count = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$managed_keys ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } @@ -152,6 +159,131 @@ class TMDO_CLI_V2 { WP_CLI::success( "Hook Bus: {$state}" ); } + /** + * Set an entity's bridge mode. Goes through TMDO_Mode_Manager::set(): unsafe + * jumps and promotions refused by the Promotion Guard (aeav_only on + * HivePress / WooCommerce sites) fail and leave the mode unchanged. + * + * ## OPTIONS + * + * + * : post | user | term | comment + * + * + * : disabled | dual_write | shadow_read | aeav_only + * + * ## EXAMPLES + * + * wp tmdo bridge-mode user dual_write + * wp tmdo bridge-mode post disabled + * + * @param array $args Entity type and mode. + * @param array $assoc_args Unused. + */ + public function bridge_mode( $args, $assoc_args ): void { + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $entity = (string) ( $args[0] ?? '' ); + $mode = (string) ( $args[1] ?? '' ); + $from = TMDO_Mode_Manager::get( $entity ); + + $result = TMDO_Mode_Manager::set( $entity, $mode ); + if ( is_wp_error( $result ) ) { + WP_CLI::error( $result->get_error_message() ); + } + WP_CLI::success( "{$entity}: {$from} → {$mode}" ); + } + + /** + * Compare an entity's flat tables with native meta, optionally repairing them. + * + * Reports per key how many values are missing from flat (miss), differ + * (diff) or exist only in flat (extra), and records the outcome per group + * in the wpdo_drift_status option. Read-only unless --fix is given. + * + * ## OPTIONS + * + * + * : post | user | term | comment + * + * [--group=] + * : Only this group. Default: every backfillable group. + * + * [--sample=] + * : Check n random entities per group instead of all of them. + * + * [--fix] + * : Rebuild drifted flat rows from native meta (native is never written), + * delete rows of deleted entities / other post types, then check again. + * Not combinable with --sample. + * + * [--format=] + * : table or json. Default: table. + * + * ## EXAMPLES + * + * wp tmdo drift-check post + * wp tmdo drift-check user --group=activity + * wp tmdo drift-check post --sample=500 + * wp tmdo drift-check post --fix + * + * @param array $args Entity type. + * @param array $assoc_args Options. + */ + public function drift_check( $args, $assoc_args ): void { + $entity = (string) ( $args[0] ?? '' ); + $group = (string) ( $assoc_args['group'] ?? '' ); + $sample = max( 0, (int) ( $assoc_args['sample'] ?? 0 ) ); + $fix = isset( $assoc_args['fix'] ); + if ( $fix && $sample ) { + WP_CLI::error( '--fix repairs every entity; it cannot be combined with --sample.' ); + return; + } + + try { + if ( $fix ) { + $result = TMDO_Drift_Check::fix( $entity, $group ); + $report = $result['after']; + WP_CLI::log( sprintf( 'Fixed: %d row(s) rebuilt from native, %d cleared, %d stray row(s) deleted.', $result['fixed']['rebuilt'], $result['fixed']['cleared'], $result['fixed']['deleted_rows'] ) ); + } else { + $report = TMDO_Drift_Check::run( $entity, $group, $sample ); + } + } catch ( InvalidArgumentException | RuntimeException $e ) { + WP_CLI::error( $e->getMessage() ); + return; + } + + if ( 'json' === ( $assoc_args['format'] ?? 'table' ) ) { + WP_CLI::line( (string) wp_json_encode( $report ) ); + return; + } + + $rows = array(); + foreach ( $report['groups'] as $group => $result ) { + foreach ( $result['keys'] as $key => $counts ) { + if ( $counts['miss'] + $counts['diff'] + $counts['extra'] > 0 ) { + $rows[] = array_merge( + array( + 'group' => $group, + 'key' => $key, + ), + $counts + ); + } + } + } + if ( $rows ) { + \WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'key', 'miss', 'diff', 'extra' ) ); + } + + $total = $report['total']; + $summary = sprintf( '%s: %d group(s)%s — miss=%d diff=%d extra=%d', $entity, count( $report['groups'] ), $sample ? " (sample {$sample})" : '', $total['miss'], $total['diff'], $total['extra'] ); + if ( $total['miss'] + $total['diff'] + $total['extra'] > 0 ) { + WP_CLI::warning( $summary ); + return; + } + WP_CLI::success( $summary ); + } + /** * Write an entity's flat-table values back to native meta — the way out of * aeav_only. Resumable; re-run after an interruption. @@ -918,6 +1050,10 @@ WP_CLI::add_command( 'wpdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' WP_CLI::add_command( 'tmdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) ); WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); +WP_CLI::add_command( 'wpdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) ); +WP_CLI::add_command( 'tmdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) ); +WP_CLI::add_command( 'wpdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) ); +WP_CLI::add_command( 'tmdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) ); WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) ); WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) ); WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) ); diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php index 6d193ef..d87c79c 100644 --- a/cli/class-tmdo-cli.php +++ b/cli/class-tmdo-cli.php @@ -94,6 +94,84 @@ class TMDO_CLI { WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) ); } + /** + * Report the flat table of every backfillable group of every entity type, + * plus the installer's migration status and points ledger tables. + * + * @return bool Every table exists. + */ + public static function check_entity_tables(): bool { + global $wpdb; + + $tables = array( $wpdb->prefix . 'wpdo_migration_status', $wpdb->prefix . 'wpdo_user_points_ledger' ); + foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) { + foreach ( TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type ) as $group ) { + $tables[] = TMDO_Schema_Manager::get_table_name( $entity_type, $group ); + } + } + + $ok = true; + foreach ( $tables as $t ) { + if ( TMDO_IS_SQLITE ) { + $exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) ); + } else { + $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) ); + } + if ( $exists ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" ); + WP_CLI::log( " [OK] {$t} ({$rows} rows)" ); + } else { + WP_CLI::warning( " [MISSING] {$t} — run `wp wpdo install` to create." ); + $ok = false; + } + } + return $ok; + } + + /** + * Report Promotion Guard violations and the recorded drift status of every + * entity that writes flat tables or has been checked (informational). + */ + public static function report_guard_and_drift(): void { + WP_CLI::log( '' ); + $violations = TMDO_Promotion_Guard::violations(); + if ( empty( $violations ) ) { + WP_CLI::log( ' [OK] Promotion Guard: no violation' ); + } + foreach ( $violations as $entity_type => $integrations ) { + WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." ); + } + + $status = get_option( TMDO_Drift_Check::OPTION, array() ); + $status = is_array( $status ) ? $status : array(); + WP_CLI::log( 'Drift (last `wp tmdo drift-check` or daily sample):' ); + foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) { + $groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type ); + // Records of groups no longer registered can be neither re-checked nor fixed. + $last = array_intersect_key( (array) ( $status[ $entity_type ] ?? array() ), array_flip( $groups ) ); + if ( empty( $groups ) || ( empty( $last ) && ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) ) { + continue; + } + if ( empty( $last ) ) { + WP_CLI::log( " {$entity_type}: never checked — run `wp tmdo drift-check {$entity_type}`" ); + continue; + } + $drifting = array(); + foreach ( $last as $group => $s ) { + if ( (int) $s['miss'] + (int) $s['diff'] + (int) $s['extra'] > 0 ) { + $drifting[] = sprintf( '%s miss=%d diff=%d extra=%d', $group, $s['miss'], $s['diff'], $s['extra'] ); + } + } + $oldest = min( array_column( $last, 'checked_at' ) ); + if ( $drifting ) { + WP_CLI::warning( " {$entity_type}: " . implode( '; ', $drifting ) . " (checked {$oldest} UTC or later) — `wp tmdo drift-check {$entity_type} --fix`" ); + } else { + WP_CLI::log( sprintf( ' [OK] %s: %d group(s) clean (oldest check %s UTC)', $entity_type, count( $last ), $oldest ) ); + } + } + } + /** * Run health check on all tables. * @@ -195,30 +273,12 @@ class TMDO_CLI { WP_CLI::log( $exists ? " [OK] {$t}" : " [NOT CREATED] {$t} (will be created on first use)" ); } - // Check user entity flat tables (v2.5.5+). - $user_entity_tables = array( - $wpdb->prefix . 'wpdo_user_membership', - $wpdb->prefix . 'wpdo_user_activity', - $wpdb->prefix . 'wpdo_user_profile', - $wpdb->prefix . 'wpdo_user_sso', - $wpdb->prefix . 'wpdo_user_points_ledger', - $wpdb->prefix . 'wpdo_migration_status', - ); - foreach ( $user_entity_tables as $t ) { - if ( TMDO_IS_SQLITE ) { - $exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) ); - } else { - $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) ); - } - if ( $exists ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" ); - WP_CLI::log( " [OK] {$t} ({$rows} rows)" ); - } else { - WP_CLI::warning( " [MISSING] {$t} — run \`wp wpdo install\` to create." ); - $all_ok = false; - } + // Entity flat tables, from the registry (was a hard-coded user list that + // named the never-registered sso group). + if ( ! self::check_entity_tables() ) { + $all_ok = false; } + self::report_guard_and_drift(); // Check partner plugin custom tables via Custom Table Registry (v2.6.0+). if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) { diff --git a/docs/adr-004-search-index-mode-for-hp-wc.md b/docs/adr-004-search-index-mode-for-hp-wc.md index 3aaad77..0885658 100644 --- a/docs/adr-004-search-index-mode-for-hp-wc.md +++ b/docs/adr-004-search-index-mode-for-hp-wc.md @@ -77,6 +77,26 @@ HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `optimize_query` / `optimize_search`。 +## 設計(Release 2.2) + +### 範圍收斂 + +- core 不再註冊 `post/wc_product`;WooCommerce AddOn 0.2.0 不再註冊 product / order / subscription 的 zone 欄位與 user `hot` 群組。WooCommerce 的 meta 只存在原生。既有表以 `bridge-restore-native post --group=wc_product --retire` 封存。 + +### 漂移偵測與修復(`TMDO_Drift_Check`) + +- `wp tmdo drift-check ` 以值比對扁平與原生(第一筆原生 row → 安全 unserialize → `Type_Caster::to_db`),逐 key 回報 miss / diff / extra,結果寫入 `wpdo_drift_status`。 +- `--fix` 從原生重建漂移列,刪除已刪實體與非本 group post type 的列,不寫原生。 +- **aeav_only 不適用**:那時扁平才是真實資料,原生本來就過期。`--fix` 拒絕、每日抽樣略過、Router 不因漂移退回原生。 +- **Router**:上次檢查有漂移的 group 不路由,整個查詢維持原生;從未檢查的 group 照常路由(決策 2026-09-27:升級後不變慢)。 +- **每日抽樣**:`wpdo_daily_health_check`,每 group 500 筆。 +- **Hook Bus 關閉期間的寫入**:entity 從 disabled 進入 dual_write / shadow_read、或 Hook Bus flag 由關轉開時,60 秒後 `tmdo_drift_resync` 執行全量 `--fix`。不用 `post_modified_gmt` 增量,因為 WooCommerce 直接 SQL 寫 meta 不會更新它。 +- **Auto-promoter** 以 `clean_since()`(2 天內乾淨的檢查)取代 shadow diff 數量;Promotion Guard 仍擋 HP / WC 站。 + +### 查詢 + +- 純排序搜尋(HivePress `_sort`、沒有路由篩選)在該 post type 每篇都有扁平列時改由扁平表驅動;Router 在 `wp_insert_post` 替新文章建空列,覆蓋率快取 15 分鐘。 + ## 後果 **好處** @@ -89,7 +109,7 @@ HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `opti - **寫入開銷**:dual_write / shadow_read 每次 meta 寫入多一次原生讀取與一次扁平寫入。dev36 實測 HP listing 整條寫入鏈 +29%、WC product +127%(後者在 2.2 移除 WC 扁平化後消失)。 - **沒有讀取加速**:帶 key 的單筆讀取仍走原生(WordPress meta cache)。 -- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 的 `drift-check` 與重新啟用時的增量 backfill(E01 / E02)處理;在那之前,大量匯入後要重新 backfill。 +- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 起由 `drift-check`(每日抽樣、Router 避開漂移中的 group)與重新啟用時的全量 `--fix` 處理;兩次抽樣之間的漂移仍可能短暫回傳過期結果,大量匯入後可手動執行 `drift-check --fix`。 - **decimal 欄位搭配 NUMERIC / DECIMAL**:原生先 CAST 成整數(`DECIMAL` 預設 (10,0)),扁平表精確比較,因此帶小數的值在區間邊界上可能不同。dev36 資料沒有這種值。 - **text 欄位超過 255 字元**存 NULL,這類值的篩選結果會與原生不同(有 `text_overflow` 記錄)。 - HivePress 搜尋 p50 為 321–866 ms(原生 600–1,713 ms),尚未達到 R-2.1 的 300 ms 目標(驗收:`notes/tmdo-acceptance-2.1-2026-09-26.md`)。價格篩選頁的 SQL 只剩約 75 ms、其餘為頁面渲染;純排序(如 `rating__desc`)必須 LEFT JOIN 保留沒有值的文章,無法 flat-first。 diff --git a/includes/class-tmdo-core.php b/includes/class-tmdo-core.php index e661487..fa8fb2e 100644 --- a/includes/class-tmdo-core.php +++ b/includes/class-tmdo-core.php @@ -296,6 +296,16 @@ class TMDO_Core { // v2.3.0 M6: daily health probe cron. add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) ); + // 1.2.0: daily flat-vs-native drift sample (read by the post query router). + add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) ); + + // 1.2.0: rebuild flat from native after writes bypassed it (entity was + // disabled, or the Hook Bus was off). + add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 ); + add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 ); + add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 ); + add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) ); + // v2.3.0 M6: daily snapshot prune cron (paired with health check). add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) ); diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index 27c1eae..7dd5fe8 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -788,6 +788,7 @@ class TMDO_Installer { wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' ); wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' ); wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' ); + wp_clear_scheduled_hook( 'tmdo_drift_resync' ); wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' ); wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' ); wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' ); @@ -991,6 +992,7 @@ class TMDO_Installer { 'wpdo_health_snapshot_monthly', 'wpdo_post_shadow_verify', 'wpdo_term_comment_shadow_verify', + 'tmdo_drift_resync', 'wpdo_post_stress_test_batch', 'wpdo_user_stress_test_batch', 'wpdo_term_stress_test_batch', diff --git a/includes/class-tmdo-post-shadow-verifier.php b/includes/class-tmdo-post-shadow-verifier.php index 6421ce7..9b74b88 100644 --- a/includes/class-tmdo-post-shadow-verifier.php +++ b/includes/class-tmdo-post-shadow-verifier.php @@ -55,7 +55,7 @@ final class TMDO_Post_Shadow_Verifier { * is available. * * @param string $post_type WP post_type to sample. - * @param string $group_name Entity group name (e.g. 'wc_product'). + * @param string $group_name Entity group name (e.g. 'hp_listing_core'). * @param string $flat_table Fully qualified flat table name. * @param string[] $keys Meta keys to compare (each post checks all). * @param int $sample_size Number of posts to sample (capped to DB count). diff --git a/includes/class-tmdo-rest-api.php b/includes/class-tmdo-rest-api.php index bdd554c..e135fb5 100644 --- a/includes/class-tmdo-rest-api.php +++ b/includes/class-tmdo-rest-api.php @@ -37,6 +37,8 @@ class TMDO_REST_API { array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_listings' ), + // Public by design: viewable post types and published posts only, fields + // declared show_in_rest only (1.1.1), per_page capped (wpdo_rest_max_per_page). 'permission_callback' => '__return_true', 'args' => $this->listings_args(), ) @@ -48,6 +50,8 @@ class TMDO_REST_API { array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_listing' ), + // Public by design: the post must be viewable to the request (assert_post_viewable_or_error: + // publicly viewable or read_post); fields declared show_in_rest only (1.1.1). 'permission_callback' => '__return_true', 'args' => array( 'id' => array( @@ -65,6 +69,7 @@ class TMDO_REST_API { array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_stats' ), + // Public by design: view / engagement counters of a post viewable to the request. 'permission_callback' => '__return_true', 'args' => array( 'id' => array( @@ -82,6 +87,8 @@ class TMDO_REST_API { array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'post_view' ), + // Public by design: anonymous visitors count views; viewable posts only, one view + // per IP per post per hour (rate limited). 'permission_callback' => '__return_true', 'args' => array( 'id' => array( diff --git a/includes/diagnostic/class-tmdo-drift-check.php b/includes/diagnostic/class-tmdo-drift-check.php new file mode 100644 index 0000000..0c06045 --- /dev/null +++ b/includes/diagnostic/class-tmdo-drift-check.php @@ -0,0 +1,532 @@ +}>, total:array{miss:int, diff:int, extra:int}} + * @throws InvalidArgumentException Unknown entity type or group. + */ + public static function run( string $entity_type, string $group = '', int $sample = 0 ): array { + list( $adapter, $groups ) = self::resolve( $entity_type, $group ); + + $report = array( + 'entity' => $entity_type, + 'sample' => $sample, + 'groups' => array(), + 'total' => array( + 'miss' => 0, + 'diff' => 0, + 'extra' => 0, + ), + ); + $status = get_option( self::OPTION, array() ); + $status = is_array( $status ) ? $status : array(); + + foreach ( $groups as $name ) { + $table = TMDO_Schema_Manager::get_table_name( $entity_type, $name ); + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + continue; + } + $result = self::check_group( $adapter, $entity_type, $name, $table, $sample ); + unset( $result['ids'] ); + + $report['groups'][ $name ] = $result; + foreach ( array( 'miss', 'diff', 'extra' ) as $kind ) { + $report['total'][ $kind ] += $result[ $kind ]; + } + $status[ $entity_type ][ $name ] = array( + 'miss' => $result['miss'], + 'diff' => $result['diff'], + 'extra' => $result['extra'], + 'sample' => $sample, + 'checked_at' => gmdate( 'Y-m-d H:i:s' ), + ); + } + + update_option( self::OPTION, $status ); + return $report; + } + + /** + * Every backfillable group of the entity was checked within $max_age + * seconds (sampled or full) and none drifted. No record counts as unclean. + * + * @param string $entity_type Entity type. + * @param int $max_age Seconds. + */ + public static function clean_since( string $entity_type, int $max_age ): bool { + $status = get_option( self::OPTION, array() ); + $status = is_array( $status ) ? ( $status[ $entity_type ] ?? array() ) : array(); + $groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type ); + if ( empty( $groups ) ) { + return false; + } + foreach ( $groups as $group ) { + $last = $status[ $group ] ?? null; + if ( ! is_array( $last ) + || (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0 + || (int) strtotime( ( $last['checked_at'] ?? '' ) . ' UTC' ) < time() - $max_age ) { + return false; + } + } + return true; + } + + /** + * Daily check (wpdo_daily_health_check): sample every entity type written + * to both native and flat (dual_write, shadow_read). In aeav_only flat is + * the source of truth and native is stale by design, so drift means nothing + * there. The outcome replaces the recorded status, so a group found + * drifting leaves the router at once. + */ + public static function cron_tick(): void { + foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) { + if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) || ! TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) { + continue; + } + try { + self::run( $entity_type, '', self::CRON_SAMPLE ); + } catch ( InvalidArgumentException $e ) { + continue; // No adapter for this entity type on this site. + } + } + } + + /** + * Entities to rebuild after a mode change: one whose flat tables were not + * written (disabled) is written next to native again, and values written + * meanwhile are missing from flat. + * + * @param string $entity_type Entity type. + * @param string $new_mode New mode. + * @param string $old_mode Previous mode. + * @return string[] + */ + public static function resync_after_mode_change( string $entity_type, string $new_mode, string $old_mode ): array { + $keeps_native = array( TMDO_Mode_Manager::MODE_DUAL_WRITE, TMDO_Mode_Manager::MODE_SHADOW_READ ); + return TMDO_Mode_Manager::MODE_DISABLED === $old_mode && in_array( $new_mode, $keeps_native, true ) ? array( $entity_type ) : array(); + } + + /** + * Entities to rebuild after the Hook Bus flag changes: no write reached + * flat while it was off. Every entity written to both native and flat + * (aeav_only keeps flat as the source of truth). + * + * @param mixed $old_value Previous flag value. + * @param mixed $value New flag value. + * @return string[] + */ + public static function resync_after_hook_bus_change( $old_value, $value ): array { + if ( self::flag_on( $old_value ) || ! self::flag_on( $value ) ) { + return array(); + } + $entities = array(); + foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) { + if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) { + $entities[] = $entity_type; + } + } + return $entities; + } + + /** + * `wpdo_bridge_mode_changed` handler. + * + * @param string $entity_type Entity type. + * @param string $new_mode New mode. + * @param string $old_mode Previous mode. + */ + public static function on_mode_changed( string $entity_type, string $new_mode, string $old_mode ): void { + self::schedule_resync( self::resync_after_mode_change( $entity_type, $new_mode, $old_mode ) ); + } + + /** + * `update_option_wpdo_hook_bus_enabled` handler. + * + * @param mixed $old_value Previous flag value. + * @param mixed $value New flag value. + */ + public static function on_hook_bus_toggled( $old_value, $value ): void { + self::schedule_resync( self::resync_after_hook_bus_change( $old_value, $value ) ); + } + + /** + * `add_option_wpdo_hook_bus_enabled` handler: the flag is stored for the first time. + * + * @param string $option Option name. + * @param mixed $value Flag value. + */ + public static function on_hook_bus_added( $option, $value ): void { + unset( $option ); + self::schedule_resync( self::resync_after_hook_bus_change( '0', $value ) ); + } + + /** + * RESYNC_HOOK handler. An entity that has meanwhile moved to aeav_only is + * left alone (fix() refuses). + * + * @param string $entity_type Entity type. + */ + public static function run_scheduled_fix( string $entity_type ): void { + try { + $result = self::fix( $entity_type ); + TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] ); + } catch ( InvalidArgumentException | RuntimeException $e ) { + TMDO_Logger::info( + 'drift_resync_skipped', + array( + 'entity' => $entity_type, + 'reason' => $e->getMessage(), + ) + ); + } + } + + /** + * Schedule one rebuild per entity type (not twice while one is pending). + * + * @param string[] $entities Entity types. + */ + private static function schedule_resync( array $entities ): void { + foreach ( $entities as $entity_type ) { + if ( ! wp_next_scheduled( self::RESYNC_HOOK, array( $entity_type ) ) ) { + wp_schedule_single_event( time() + self::RESYNC_DELAY, self::RESYNC_HOOK, array( $entity_type ) ); + } + } + } + + /** + * Whether a stored Hook Bus flag value means "on" (as TMDO_Hook_Bus_Bridge reads it). + * + * @param mixed $value Flag value. + */ + private static function flag_on( $value ): bool { + return '1' === (string) $value || true === $value; + } + + /** + * Rebuild drifted flat rows from native meta, then check again. + * + * Rows of deleted entities (or, for post groups, of posts outside the + * group's post types) are deleted. Every other entity with drift in any key + * gets its whole row rebuilt from native; when native holds none of the + * group's keys the row is kept with its columns cleared. Row caches of the + * touched entities are invalidated. Native meta is never written. + * + * @param string $entity_type post | user | term | comment. + * @param string $group One group, or '' for every backfillable group. + * @return array{fixed:array{rebuilt:int, cleared:int, deleted_rows:int}, after:array} + * @throws RuntimeException In aeav_only, where flat is the source of truth + * (and InvalidArgumentException via resolve() for an + * unknown entity type or group). + */ + public static function fix( string $entity_type, string $group = '' ): array { + global $wpdb; + list( $adapter, $groups ) = self::resolve( $entity_type, $group ); + if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ) { + throw new RuntimeException( esc_html( $entity_type ) . ' is in aeav_only: flat is the source of truth and native meta is stale, so it cannot be rebuilt from native. Write flat back first (wp tmdo bridge-restore-native).' ); + } + + $id = $adapter->get_entity_id_column(); + $fixed = array( + 'rebuilt' => 0, + 'cleared' => 0, + 'deleted_rows' => 0, + ); + foreach ( $groups as $name ) { + $table = TMDO_Schema_Manager::get_table_name( $entity_type, $name ); + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + continue; + } + + foreach ( self::stray_ids( $adapter, $entity_type, $name, $table ) as $eid ) { + $fixed['deleted_rows'] += (int) $wpdb->delete( $table, array( $id => $eid ), array( '%d' ) ); + TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name ); + } + + foreach ( self::check_group( $adapter, $entity_type, $name, $table, 0 )['ids'] as $eid ) { + if ( TMDO_Entity_Migration_Engine::migrate_entity( $entity_type, $name, $eid ) ) { + ++$fixed['rebuilt']; + } else { + self::clear_row( $entity_type, $name, $table, $id, $eid ); + ++$fixed['cleared']; + } + TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name ); + } + } + + return array( + 'fixed' => $fixed, + 'after' => self::run( $entity_type, $group ), + ); + } + + /** + * Adapter and group list for a run. + * + * @param string $entity_type Entity type. + * @param string $group One group, or '' for every backfillable group. + * @return array{0:TMDO_Entity_Adapter_Interface, 1:string[]} + * @throws InvalidArgumentException Unknown entity type or group. + */ + private static function resolve( string $entity_type, string $group ): array { + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + if ( ! $adapter ) { + throw new InvalidArgumentException( 'Unknown entity type: ' . esc_html( $entity_type ) ); + } + $groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type ); + if ( '' !== $group ) { + if ( ! in_array( $group, $groups, true ) ) { + throw new InvalidArgumentException( 'Unknown or orphan ' . esc_html( $entity_type ) . ' group: ' . esc_html( $group ) ); + } + $groups = array( $group ); + } + return array( $adapter, $groups ); + } + + /** + * Compare every key of one group. + * + * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. + * @param string $entity_type Entity type. + * @param string $group Group name. + * @param string $table Group flat table. + * @param int $sample 0, or the number of random entities. + * @return array{miss:int, diff:int, extra:int, keys:array, ids:int[]} + */ + private static function check_group( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table, int $sample ): array { + global $wpdb; + + $meta = $adapter->get_native_meta_table(); + $id = $adapter->get_entity_id_column(); + $mid = 'user' === $entity_type ? 'umeta_id' : 'meta_id'; + $scope = self::scope_sql( $adapter, $entity_type, $group, $sample ); + $result = array( + 'miss' => 0, + 'diff' => 0, + 'extra' => 0, + 'keys' => array(), + 'ids' => array(), + ); + + foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) { + $key = (string) $field['key']; + $col = TMDO_Schema_Manager::sanitize_column_name( $key ); + $type = $field['type'] ?? 'text'; + + // First native row per entity — what a single get_*_meta() read returns. + $native = $wpdb->prepare( "SELECT m.`{$id}` AS eid, m.meta_value AS v FROM `{$meta}` m JOIN ( SELECT `{$id}`, MIN(`{$mid}`) AS mid FROM `{$meta}` WHERE meta_key = %s GROUP BY `{$id}` ) x ON x.mid = m.`{$mid}` WHERE 1=1", $key ) + . str_replace( '{id}', "m.`{$id}`", $scope ); + + // Text beyond the column width is stored as NULL by design (Type_Caster::fit_text). + $fits = 'text' === $type ? ' AND CHAR_LENGTH(n.v) <= ' . TMDO_Type_Caster::TEXT_MAX_CHARS : ''; + $miss = array_map( 'intval', $wpdb->get_col( "SELECT n.eid FROM ( {$native} ) n LEFT JOIN `{$table}` f ON f.`{$id}` = n.eid WHERE LENGTH(n.v) > 0 AND f.`{$col}` IS NULL{$fits}" ) ); + + $candidates = $wpdb->get_results( + "SELECT n.eid, n.v, f.`{$col}` AS flat FROM ( {$native} ) n JOIN `{$table}` f ON f.`{$id}` = n.eid + WHERE f.`{$col}` IS NOT NULL AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY n.v + OR ( n.v REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6)) ) )", + ARRAY_A + ); + $diff = array(); + foreach ( (array) $candidates as $row ) { + if ( ! self::same( (string) $row['v'], (string) $row['flat'], $field ) ) { + $diff[] = (int) $row['eid']; + } + } + + $absent = $wpdb->prepare( "NOT EXISTS ( SELECT 1 FROM `{$meta}` m WHERE m.`{$id}` = f.`{$id}` AND m.meta_key = %s )", $key ); + if ( ! empty( $field['required'] ) ) { + $default = (string) TMDO_Type_Caster::to_db( null, $field ); + $absent .= $wpdb->prepare( + " AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY %s OR ( %s REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(%s AS DECIMAL(30,6)) ) )", + $default, + $default, + $default + ); + } + // Every flat row counts, including rows of other post types or deleted + // entities; only sample mode narrows the rows. + $extra = array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f WHERE f.`{$col}` IS NOT NULL AND {$absent}" . ( $sample > 0 ? str_replace( '{id}', "f.`{$id}`", $scope ) : '' ) ) ); + + $counts = array( + 'miss' => count( $miss ), + 'diff' => count( $diff ), + 'extra' => count( $extra ), + ); + $result['keys'][ $key ] = $counts; + $result['miss'] += $counts['miss']; + $result['diff'] += $counts['diff']; + $result['extra'] += $counts['extra']; + $result['ids'] = array_merge( $result['ids'], $miss, $diff, $extra ); + } + + $result['ids'] = array_values( array_unique( $result['ids'] ) ); + return $result; + } + + /** + * Flat rows whose entity no longer exists or, for post groups, whose post + * is outside the group's post types. + * + * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. + * @param string $entity_type Entity type. + * @param string $group Group name. + * @param string $table Group flat table. + * @return int[] + */ + private static function stray_ids( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table ): array { + global $wpdb; + + $id = $adapter->get_entity_id_column(); + $primary = $adapter->get_primary_table(); + $pk = $adapter->get_primary_id_column(); + $post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array(); + $type_sql = $post_types ? " AND p.post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')" : ''; + + return array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f LEFT JOIN `{$primary}` p ON p.`{$pk}` = f.`{$id}`{$type_sql} WHERE p.`{$pk}` IS NULL" ) ); + } + + /** + * Clear a row's columns: native holds none of the group's keys. The row is + * kept (NULL-only rows are not deleted, as the router may join on them); + * required columns go back to their default. + * + * @param string $entity_type Entity type. + * @param string $group Group name. + * @param string $table Group flat table. + * @param string $id Entity id column. + * @param int $eid Entity id. + */ + private static function clear_row( string $entity_type, string $group, string $table, string $id, int $eid ): void { + global $wpdb; + + $data = array(); + foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) { + $data[ TMDO_Schema_Manager::sanitize_column_name( (string) $field['key'] ) ] = TMDO_Type_Caster::to_db( null, $field ); + } + $wpdb->update( $table, $data, array( $id => $eid ) ); + } + + /** + * SQL restricting the checked entities: the group's post types, and in + * sample mode a random set of entity ids. `{id}` stands for the id column. + * + * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. + * @param string $entity_type Entity type. + * @param string $group Group name. + * @param int $sample 0, or the number of random entities. + */ + private static function scope_sql( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, int $sample ): string { + global $wpdb; + + $post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array(); + $type_sql = $post_types + ? " post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')" + : ''; + + if ( $sample <= 0 ) { + return $type_sql ? " AND {id} IN ( SELECT ID FROM {$wpdb->posts} WHERE{$type_sql} )" : ''; + } + + $primary = $adapter->get_primary_table(); + $pk = $adapter->get_primary_id_column(); + $ids = $wpdb->get_col( "SELECT `{$pk}` FROM `{$primary}`" . ( $type_sql ? " WHERE{$type_sql}" : '' ) . ' ORDER BY RAND() LIMIT ' . (int) $sample ); + return ' AND {id} IN (' . ( $ids ? implode( ',', array_map( 'intval', $ids ) ) : '0' ) . ')'; + } + + /** + * Whether a flat value is what native meta would store. + * + * @param string $native Raw native meta_value. + * @param string $flat Flat column value. + * @param array $field Registered field definition. + */ + private static function same( string $native, string $flat, array $field ): bool { + $type = $field['type'] ?? 'text'; + $value = TMDO_Safe_Unserialize::run( $native ); + if ( 'text' === $type && is_scalar( $value ) && mb_strlen( (string) $value ) > TMDO_Type_Caster::TEXT_MAX_CHARS ) { + return false; // Belongs in flat as NULL, yet this row holds a value. + } + + $expected = TMDO_Type_Caster::to_db( $value, $field ); + if ( null === $expected ) { + return false; + } + if ( 'json' === $type ) { + // Key order and int-vs-float formatting are not drift. + return json_decode( $flat, true ) == json_decode( (string) $expected, true ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + } + if ( is_numeric( $expected ) && is_numeric( $flat ) ) { + return abs( (float) $expected - (float) $flat ) < 0.000001; + } + return (string) $expected === $flat; + } +} diff --git a/includes/diagnostic/class-tmdo-health-cron.php b/includes/diagnostic/class-tmdo-health-cron.php index 3bf0e66..a08c166 100644 --- a/includes/diagnostic/class-tmdo-health-cron.php +++ b/includes/diagnostic/class-tmdo-health-cron.php @@ -104,7 +104,7 @@ class TMDO_Health_Cron { } } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check. - error_log( '[WPDO] Module detector exception in health cron: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + TMDO_Logger::error( 'health_cron', 'module_detector', $e->getMessage() ); } } diff --git a/includes/engine/class-tmdo-auto-promoter.php b/includes/engine/class-tmdo-auto-promoter.php index 5f3983a..d7ffced 100644 --- a/includes/engine/class-tmdo-auto-promoter.php +++ b/includes/engine/class-tmdo-auto-promoter.php @@ -6,7 +6,8 @@ * 判斷條件(皆滿足才觸發): * 1. 當前 mode = shadow_read * 2. 進入 shadow_read 距今 ≥ `min_days` - * 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0 + * 3. 該 entity 每個群組最近一次 drift-check(2 天內,抽樣或全量)皆無漂移 + * (1.2.0 起取代 shadow_diffs 筆數;無紀錄不升級) * * 這符合「穩定期沒問題就進 production」的安全遷移原則。 * 若 diff 出現,規則永不觸發;人工介入即可。 @@ -35,6 +36,9 @@ final class TMDO_Auto_Promoter { public const CRON_HOOK = 'wpdo_auto_promote_check'; + /** A drift check older than this (seconds) does not count for promotion. */ + public const DRIFT_MAX_AGE = 2 * DAY_IN_SECONDS; + public static function init(): void { add_action( self::CRON_HOOK, array( self::class, 'check_all' ) ); add_action( 'init', array( self::class, 'maybe_schedule' ), 30 ); @@ -82,7 +86,7 @@ final class TMDO_Auto_Promoter { /** * 評估單一 entity 是否可升級。 * - * @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'has_diff' | 'vetoed' + * @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'drift_not_clean' | 'vetoed' | 'error' */ public static function check_entity( string $type, int $min_days, array $entered_all ): string { $current = TMDO_Mode_Manager::get( $type ); @@ -95,14 +99,13 @@ final class TMDO_Auto_Promoter { return 'not_ready'; } - $diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity(); - $count = (int) ( $diff_counts[ $type ] ?? 0 ); - if ( $count > 0 ) { - return 'has_diff'; + if ( ! TMDO_Drift_Check::clean_since( $type, self::DRIFT_MAX_AGE ) ) { + return 'drift_not_clean'; } - // 讓外部 filter 最後一次 veto(例如:還在外部驗證期間) - $should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count ); + // 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)。第 4 參數原為 + // shadow diff 筆數;1.2.0 起改看 drift,固定傳 0。 + $should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, 0 ); if ( ! $should ) { return 'vetoed'; } diff --git a/includes/engine/class-tmdo-entity-migration-engine.php b/includes/engine/class-tmdo-entity-migration-engine.php index 8d262be..72855f1 100644 --- a/includes/engine/class-tmdo-entity-migration-engine.php +++ b/includes/engine/class-tmdo-entity-migration-engine.php @@ -144,13 +144,14 @@ final class TMDO_Entity_Migration_Engine { } } catch ( \Throwable $e ) { ++$stats['errors']; - error_log( - sprintf( - '[UAE Migration] Error %s/%s ID=%d: %s', - $entity_type, - $group_name, - $entity_id, - $e->getMessage() + TMDO_Logger::error( + 'migration_engine', + 'migrate_group', + $e->getMessage(), + array( + 'entity_type' => $entity_type, + 'group' => $group_name, + 'entity_id' => $entity_id, ) ); } @@ -182,6 +183,28 @@ final class TMDO_Entity_Migration_Engine { return $stats; } + /** + * Rebuild one entity's row of a group from native meta. + * + * @return bool False when native holds none of the group's keys (nothing written). + */ + public static function migrate_entity( string $entity_type, string $group_name, int $entity_id ): bool { + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + $group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); + if ( ! $adapter || empty( $group_fields ) ) { + return false; + } + return self::migrate_single_entity( + $entity_type, + $group_name, + $entity_id, + array_column( $group_fields, 'key' ), + array_column( $group_fields, null, 'key' ), + $adapter, + false + ); + } + /** * 遷移單一 entity 的所有 meta */ @@ -243,6 +266,18 @@ final class TMDO_Entity_Migration_Engine { return false; } + // REPLACE rewrites the whole row: a column left out falls back to its DDL + // default (e.g. session_count DEFAULT 0), inventing a value the object never + // had. Keys without a native row are written as NULL; required columns are + // NOT NULL, so they keep their default (treated as "absent" by the diff logger). + foreach ( $field_map as $key => $field_def ) { + $col = TMDO_Schema_Manager::sanitize_column_name( $key ); + if ( ! array_key_exists( $col, $data ) && empty( $field_def['required'] ) ) { + $data[ $col ] = null; + $formats[] = '%s'; + } + } + if ( $dry_run ) { return true; } @@ -349,7 +384,16 @@ final class TMDO_Entity_Migration_Engine { ++$batch_migrated; } } catch ( \Throwable $e ) { - error_log( sprintf( '[UAE Migration Batch] Error %s/%s ID=%d: %s', $entity_type, $group_name, $entity_id, $e->getMessage() ) ); + TMDO_Logger::error( + 'migration_engine', + 'migrate_group_batch', + $e->getMessage(), + array( + 'entity_type' => $entity_type, + 'group' => $group_name, + 'entity_id' => $entity_id, + ) + ); } $last_id = $entity_id; } @@ -374,6 +418,23 @@ final class TMDO_Entity_Migration_Engine { ); } + /** + * Registered groups of an entity type that can be backfilled from native + * meta. Orphan groups restored from wpdo_registry_meta are left out: their + * values live only in the flat table, a backfill would wipe them. + * + * @return string[] + */ + public static function backfillable_groups( string $entity_type ): array { + $orphans = array(); + foreach ( TMDO_Entity_Registry::get_restored_groups() as $restored ) { + if ( $entity_type === $restored['entity_type'] ) { + $orphans[] = $restored['group']; + } + } + return array_values( array_diff( TMDO_Entity_Registry::get_groups_for_type( $entity_type ), $orphans ) ); + } + // ───────────────────────────────────────────────────────── // 斷點管理 // ───────────────────────────────────────────────────────── diff --git a/includes/engine/class-tmdo-shadow-diff-logger.php b/includes/engine/class-tmdo-shadow-diff-logger.php index 766b8fa..0644b86 100644 --- a/includes/engine/class-tmdo-shadow-diff-logger.php +++ b/includes/engine/class-tmdo-shadow-diff-logger.php @@ -31,6 +31,14 @@ final class TMDO_Shadow_Diff_Logger { /** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */ public const RATELIMIT_WINDOW = 300; + /** + * Rows per entity type + key per KEY_WINDOW, across all objects: a key + * stale on every object (WooCommerce total_sales, written by direct SQL) + * must not log a row for each object read. + */ + public const KEY_LIMIT = 20; + public const KEY_WINDOW = 3600; + public static function table_name(): string { global $wpdb; return $wpdb->prefix . 'wpdo_shadow_diffs'; @@ -69,7 +77,7 @@ final class TMDO_Shadow_Diff_Logger { } // 不一致 → 檢查 rate limit - if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) ) { + if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) || self::key_over_limit( $entity_type, $meta_key ) ) { return; } @@ -183,6 +191,21 @@ final class TMDO_Shadow_Diff_Logger { return false; } + /** + * Count this diff against the key's KEY_LIMIT; true once the limit is + * reached within the current KEY_WINDOW (the window starts at its first row). + */ + private static function key_over_limit( string $entity_type, string $meta_key ): bool { + $key = 'wpdo_sd_kl_' . md5( "{$entity_type}:{$meta_key}" ); + $state = get_transient( $key ); + $state = is_array( $state ) ? $state : array( 0, time() ); + if ( $state[0] >= self::KEY_LIMIT ) { + return true; + } + set_transient( $key, array( $state[0] + 1, $state[1] ), max( 1, $state[1] + self::KEY_WINDOW - time() ) ); + return false; + } + /** * 寫入 diff row * diff --git a/includes/engine/class-tmdo-type-caster.php b/includes/engine/class-tmdo-type-caster.php index 44c2bff..9faa080 100644 --- a/includes/engine/class-tmdo-type-caster.php +++ b/includes/engine/class-tmdo-type-caster.php @@ -84,7 +84,7 @@ final class TMDO_Type_Caster { } /** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */ - private const TEXT_MAX_CHARS = 255; + public const TEXT_MAX_CHARS = 255; /** * Keep a text value only if it fits its VARCHAR(255) column; otherwise diff --git a/includes/integrations/class-tmdo-post-fields.php b/includes/integrations/class-tmdo-post-fields.php index 5057c1f..511b144 100644 --- a/includes/integrations/class-tmdo-post-fields.php +++ b/includes/integrations/class-tmdo-post-fields.php @@ -2,19 +2,18 @@ /** * Post entity field registration for WP Data Optimizer (v2.9.1). * - * Registers four post entity groups designed to absorb the bulk of + * Registers three post entity groups designed to absorb the bulk of * wp_postmeta rows that previously bypassed any flat-table strategy. * All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV. * * Groups → flat tables (post_type targets): * wp_core → wp_wpdo_post_wp_core (cross post_type) * attachment → wp_wpdo_post_attachment (attachment) - * wc_product → wp_wpdo_post_wc_product (product) * nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item) * * Pattern mirrors TMDO_Member_Fields::register(). Called from * TMDO_Core::run() before the wpdo_register_entity_fields action fires; - * Schema_Manager materializes the four flat tables in init:1 + * Schema_Manager materializes the three flat tables in init:1 * via process_pending_migrations() (idempotent via schema_hash compare). * * Keys not registered here (HivePress dynamic attrs, WPCS legacy keys, @@ -39,7 +38,10 @@ if ( ! defined( 'ABSPATH' ) ) { } /** - * Registers four post entity groups. + * Registers three post entity groups. WooCommerce product meta is not one of + * them (1.2.0): WooCommerce reads it directly and writes stock by SQL, so + * native meta stays its only copy. Existing wp_wpdo_post_wc_product tables + * are left alone (see `wp tmdo bridge-restore-native post --group=wc_product --retire`). */ final class TMDO_Post_Fields { @@ -49,7 +51,6 @@ final class TMDO_Post_Fields { */ private const GROUP_POST_TYPES = array( 'attachment' => 'attachment', - 'wc_product' => 'product', 'nav_menu_item' => 'nav_menu_item', ); @@ -104,7 +105,6 @@ final class TMDO_Post_Fields { self::register_wp_core_group(); self::register_attachment_group(); - self::register_wc_product_group(); self::register_nav_menu_item_group(); } @@ -176,131 +176,6 @@ final class TMDO_Post_Fields { ); } - /** - * WooCommerce product core meta keys (post_type=product). - * 19 keys — covers ~94% of wp_postmeta rows for products on dev10. - * - * @return void - */ - private static function register_wc_product_group(): void { - TMDO_Entity_Registry::register_group( - 'post', - 'wc_product', - array( - array( - 'key' => '_price', - 'type' => 'decimal', - 'searchable' => true, - 'label' => 'Effective price', - ), - array( - 'key' => '_regular_price', - 'type' => 'decimal', - 'searchable' => true, - 'label' => 'Regular price', - ), - array( - 'key' => '_sale_price', - 'type' => 'decimal', - 'searchable' => true, - 'label' => 'Sale price', - ), - array( - 'key' => '_stock', - 'type' => 'integer', - 'searchable' => true, - 'label' => 'Stock quantity', - ), - array( - 'key' => '_stock_status', - 'type' => 'enum', - 'searchable' => true, - 'options' => array( 'instock', 'outofstock', 'onbackorder' ), - 'label' => 'Stock status', - ), - array( - 'key' => '_sku', - 'type' => 'text', - 'searchable' => true, - 'label' => 'Product SKU', - ), - array( - 'key' => '_manage_stock', - 'type' => 'enum', - 'options' => array( 'yes', 'no' ), - 'label' => 'Manage stock?', - ), - array( - 'key' => '_backorders', - 'type' => 'enum', - 'options' => array( 'yes', 'no', 'notify' ), - 'label' => 'Allow backorders?', - ), - array( - 'key' => '_sold_individually', - 'type' => 'enum', - 'options' => array( 'yes', 'no' ), - 'label' => 'Sold individually?', - ), - array( - 'key' => '_virtual', - 'type' => 'enum', - 'options' => array( 'yes', 'no' ), - 'label' => 'Virtual product?', - ), - array( - 'key' => '_downloadable', - 'type' => 'enum', - 'options' => array( 'yes', 'no' ), - 'label' => 'Downloadable?', - ), - array( - 'key' => '_tax_class', - 'type' => 'text', - 'label' => 'Tax class slug', - ), - array( - 'key' => '_tax_status', - 'type' => 'enum', - 'options' => array( 'taxable', 'shipping', 'none' ), - 'label' => 'Tax status', - ), - array( - 'key' => '_download_limit', - 'type' => 'integer', - 'label' => 'Download limit', - ), - array( - 'key' => '_download_expiry', - 'type' => 'integer', - 'label' => 'Download expiry days', - ), - array( - 'key' => '_product_version', - 'type' => 'text', - 'label' => 'WC version product was created on', - ), - array( - 'key' => '_wc_average_rating', - 'type' => 'decimal', - 'searchable' => true, - 'label' => 'Average rating', - ), - array( - 'key' => '_wc_review_count', - 'type' => 'integer', - 'label' => 'Review count', - ), - array( - 'key' => 'total_sales', - 'type' => 'integer', - 'searchable' => true, - 'label' => 'Total sales count', - ), - ) - ); - } - /** * Nav menu item meta keys (post_type=nav_menu_item). * diff --git a/includes/migration/class-tmdo-post-migration.php b/includes/migration/class-tmdo-post-migration.php index 26caf95..9059b47 100644 --- a/includes/migration/class-tmdo-post-migration.php +++ b/includes/migration/class-tmdo-post-migration.php @@ -99,11 +99,12 @@ final class TMDO_Post_Migration { /** * Bulk SQL pivot for one group: select managed keys from wp_postmeta, * filter by post_type, pivot via MAX(CASE) GROUP BY post_id, UPSERT - * into the group's flat table. + * into the group's flat table. Groups with json fields go row by row + * through backfill_group_json() instead. * - * @param string $group_name Entity group name (e.g. 'wc_product'). + * @param string $group_name Entity group name (e.g. 'hp_listing_core'). * @return array{migrated:int,group:string,post_type:string|null} - * @throws InvalidArgumentException When group is not registered. + * @throws InvalidArgumentException When group is not registered, or is a restored orphan group. * @throws RuntimeException When the pivot SQL fails. */ public static function backfill_group( string $group_name ): array { @@ -115,8 +116,30 @@ final class TMDO_Post_Migration { 'Unknown post entity group: ' . esc_html( $group_name ) ); } + if ( ! in_array( $group_name, self::backfillable_groups(), true ) ) { + throw new InvalidArgumentException( + 'Orphan post group, its values live only in the flat table: ' . esc_html( $group_name ) + ); + } + + $post_type = self::group_post_type( $group_name ); + + // json values need PHP-level unserialize → json encode, which the bulk + // pivot below cannot do: backfill such groups row by row instead. + if ( in_array( 'json', array_column( $fields, 'type' ), true ) ) { + $result = self::backfill_group_json( $group_name, array( 'resume' => false ) ); + if ( ! empty( $result['error'] ) || ! empty( $result['errors'] ) ) { + throw new RuntimeException( + 'backfill_group failed for ' . esc_html( $group_name ) . ': ' . esc_html( (string) ( $result['error'] ?? $result['errors'] . ' row error(s)' ) ) + ); + } + return array( + 'migrated' => (int) $result['migrated'], + 'group' => $group_name, + 'post_type' => $post_type, + ); + } - $post_type = self::group_post_type( $group_name ); $flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name ); // Introspect target table columns so we only pivot fields that actually @@ -131,16 +154,10 @@ final class TMDO_Post_Migration { } // Build column list and CASE expressions. - // Skip json/textarea types from the bulk pivot — those need row-by-row - // unserialize handling (deferred to v2.9.4 backfill_unserialize phase). $columns = array(); $cases = array(); $update_parts = array(); foreach ( $fields as $field ) { - $type = $field['type'] ?? 'text'; - if ( 'json' === $type ) { - continue; - } $col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] ); if ( ! isset( $existing_cols[ $col ] ) ) { continue; // Column not present in this table — skip silently. @@ -536,6 +553,17 @@ final class TMDO_Post_Migration { // Helpers // ───────────────────────────────────────────────────────────────────────── + /** + * Registered post groups that can be backfilled from wp_postmeta. Orphan + * groups restored from wpdo_registry_meta are left out: their values live + * only in the flat table. + * + * @return string[] + */ + public static function backfillable_groups(): array { + return TMDO_Entity_Migration_Engine::backfillable_groups( self::ENTITY_TYPE ); + } + /** * All meta_keys registered for any post entity group. * diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index bf1f1ac..db90998 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -69,6 +69,9 @@ class TMDO_Post_Query_Router { /** Object cache group for found-rows counts. */ private const COUNT_CACHE_GROUP = 'tmdo_found_rows'; + /** Seconds a "every post of the type has a flat row" answer is reused. */ + private const COVERAGE_TTL = 900; + /** Comparison operators the router can express on a flat column. */ private const COMPARES = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ); @@ -95,6 +98,30 @@ class TMDO_Post_Query_Router { add_filter( 'split_the_query', array( $this, 'split_the_query' ), 10, 4 ); add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 ); add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 ); + add_action( 'wp_insert_post', array( $this, 'add_flat_rows' ), 10, 3 ); + } + + /** + * A new post of a type with its own groups gets an empty row in each, so + * that "every post of the type has a flat row" keeps holding and sort-only + * searches can drive from the flat table (see flat_first()). + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post. + * @param bool $update Whether an existing post was updated. + * @return void + */ + public function add_flat_rows( $post_id, $post, $update ): void { + if ( $update || ! $post instanceof \WP_Post ) { + return; + } + global $wpdb; + foreach ( TMDO_Post_Fields::groups_for_post_type( $post->post_type ) as $group ) { + $table = TMDO_Schema_Manager::get_table_name( 'post', $group ); + if ( TMDO_Schema_Manager::table_exists( $table ) ) { + $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`) VALUES (%d)", (int) $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + } } /** @@ -422,6 +449,57 @@ class TMDO_Post_Query_Router { self::$copies_usable = array(); } + /** + * Every post of the type has a row in the group's flat table. One + * anti-join per group, reused for COVERAGE_TTL and per post-mode change. + * A post inserted by direct SQL (no wp_insert_post) is seen at the next + * check at the latest; the `tmdo_router_flat_first` filter turns the + * flat-first route off. + * + * @param string $post_type Post type. + * @param string $group Entity group name. + * @return bool + */ + private static function covers_all( string $post_type, string $group ): bool { + $key = self::coverage_key( $post_type, $group ); + $hit = get_transient( $key ); + if ( false === $hit ) { + global $wpdb; + $table = TMDO_Schema_Manager::get_table_name( 'post', $group ); + $missing = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} p LEFT JOIN `{$table}` f ON f.post_id = p.ID WHERE p.post_type = %s AND f.post_id IS NULL LIMIT 1", $post_type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $hit = null === $missing ? 'yes' : 'no'; + set_transient( $key, $hit, self::COVERAGE_TTL ); + } + return 'yes' === $hit; + } + + /** + * Forget every cached coverage answer (tests, or after bulk writes that + * bypass wp_insert_post). + * + * @return void + */ + public static function forget_coverage(): void { + foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) { + foreach ( TMDO_Entity_Registry::get_group_post_types( 'post', $group ) as $post_type ) { + delete_transient( self::coverage_key( $post_type, $group ) ); + } + } + } + + /** + * Transient name of a coverage answer; a post-mode change starts a new one. + * + * @param string $post_type Post type. + * @param string $group Entity group name. + * @return string + */ + private static function coverage_key( string $post_type, string $group ): string { + $entered = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() ); + $since = is_array( $entered ) ? (int) ( $entered['post'] ?? 0 ) : 0; + return 'tmdo_cov_' . md5( "{$post_type}:{$group}:{$since}" ); + } + /** * The status / date copy route for a routed query, or [] when it does not apply. * @@ -523,9 +601,27 @@ class TMDO_Post_Query_Router { return false; } + /** + * The last `wp tmdo drift-check` found the group out of step with native + * meta. A group never checked keeps routing, and so does every group in + * aeav_only, where native is stale by design and no fallback. + * + * @param string $group Entity group name. + * @return bool + */ + private static function drifting( string $group ): bool { + if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) { + return false; + } + $status = get_option( TMDO_Drift_Check::OPTION, array() ); + $last = is_array( $status ) ? ( $status['post'][ $group ] ?? null ) : null; + return is_array( $last ) && (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0; + } + /** * The routed form of a clause, or null when the flat column would not - * compare the way WordPress compares the meta_value string. + * compare the way WordPress compares the meta_value string, or when the + * group is known to drift from native meta. * * Native casts meta_value by the clause type (CHAR when absent); flat * columns are typed. Routed: EXISTS / NOT EXISTS; string columns with @@ -560,7 +656,7 @@ class TMDO_Post_Query_Router { && in_array( $compare, array( '=', '!=', '<>', 'IN', 'NOT IN' ), true ) && array() === array_filter( (array) $value, static fn( $v ) => ! is_scalar( $v ) || (string) (int) $v !== (string) $v ); } - if ( ! $ok ) { + if ( ! $ok || self::drifting( (string) $field['group'] ) ) { return null; } @@ -603,14 +699,25 @@ class TMDO_Post_Query_Router { private static function flat_first( string $request, \WP_Query $query ): string { $routed = $query->get( self::QUERY_VAR ); $sort = $query->get( self::SORT_VAR ); - if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) { + if ( empty( $sort ) || ! is_array( $sort ) || ! empty( $query->get( self::COPY_VAR ) ) ) { return $request; } - $post_type = (string) array_key_first( $routed ); - $groups = array_unique( array_column( $routed[ $post_type ], 'group' ) ); - $compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) ); - if ( empty( $sort ) || 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) { - return $request; + if ( empty( $routed ) || ! is_array( $routed ) ) { + // Sort only: the LEFT JOIN keeps posts without a flat row, so the + // flat table may drive only when every post of the type has one. + $post_type = (string) $sort['post_type']; + $group = (string) $sort['group']; + if ( ! self::covers_all( $post_type, $group ) ) { + return $request; + } + } else { + $post_type = (string) array_key_first( $routed ); + $groups = array_unique( array_column( $routed[ $post_type ], 'group' ) ); + $compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) ); + if ( 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) { + return $request; + } + $group = (string) $groups[0]; } foreach ( array( 'p', 'page_id', 'name', 'pagename', 'attachment_id', 'post__in', 'post_parent', 'post_parent__in', 'author', 'author__in', 'author_name' ) as $var ) { if ( ! empty( $query->get( $var ) ) ) { @@ -622,8 +729,8 @@ class TMDO_Post_Query_Router { } global $wpdb; - $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $groups[0] ); - $alias = self::table_alias( $post_type, $groups[0] ); + $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group ); + $alias = self::table_alias( $post_type, $group ); $on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)"; $join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}"; if ( 1 !== substr_count( $request, $join ) ) { @@ -639,6 +746,13 @@ class TMDO_Post_Query_Router { if ( 1 !== $replaced ) { return $request; } + // WordPress groups by posts.ID for any tax or meta query. With the flat row + // (one per post) as the only join there is nothing to collapse, and the + // GROUP BY makes MySQL sort the whole match in a temporary table + // (WooCommerce's product_visibility NOT IN on searches is a subquery). + if ( 1 === preg_match_all( '/\bJOIN\b/i', (string) $flat_first ) ) { + $flat_first = preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', (string) $flat_first, 1 ); + } // Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id. $flat_first = preg_replace_callback( '/\bORDER BY\b(?!.*\bORDER BY\b).*$/s', diff --git a/readme.txt b/readme.txt index 4abaa85..7cb6d7b 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.1.1 +Stable tag: 1.2.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -107,6 +107,18 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency, == Changelog == += 1.2.0 = +* `wp tmdo drift-check [--group] [--sample] [--fix]`: reports flat-vs-native drift per key (miss / diff / extra) and rebuilds drifted flat rows from native; native meta is never written, and aeav_only sites are refused +* The post query router keeps a query native while one of its groups is recorded as drifting; a daily sample (500 entities per group) on the existing health-check event keeps the record current +* Switching an entity from disabled to dual_write / shadow_read, or turning the Hook Bus back on, schedules a full drift fix 60 s later (`tmdo_drift_resync`) +* Auto-promotion requires a clean drift check within 2 days; shadow_read diffs are capped at 20 per key per hour +* `wp tmdo bridge-mode ` (under the Promotion Guard); `member-backfill --all`; post / user backfill groups come from the registry, json groups included +* WooCommerce: the `post/wc_product` group is no longer registered — product meta stays native (retire existing tables with `bridge-restore-native post --group=wc_product --retire`) +* Sort-only searches drive from the flat table when every post of the type has a flat row (HivePress rating sort ~3.3 s → ~0.5–0.85 s on 100k listings) +* `wp tmdo cleanup-hp-transients --options` removes HivePress AddOn transients left in wp_options +* Fixed: entity backfill invented default values (0) for keys with no native row; doctor, term-comment-diagnose and bridge-status false alarms; member-cutover fatal on a refused transition +* Admin output escaping and logging clean-ups from the 2.1 audit + = 1.1.1 = * Security: the public GET /wpdo/v1/listings endpoints returned every registered field (show_in_rest defaulted to true), including private values such as integration tokens and WooCommerce purchase notes. Fields are now private unless registered with show_in_rest => true, and protected (underscore) or credential-like keys are never exposed * Security: the listings endpoint no longer returns whole rows when no field is public, and only filters / sorts on public columns diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 91cf5f2..c1fbd05 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -506,10 +506,12 @@ if ( ! function_exists( 'register_rest_route' ) ) { // WP_CLI stub (needed before loading CLI classes). if ( ! class_exists( 'WP_CLI' ) ) { class WP_CLI { - public static function log( string $msg ): void {} - public static function warning( string $msg ): void {} - public static function success( string $msg ): void {} - public static function error( string $msg ): void {} + // Output is kept in $GLOBALS['_wp_cli_output'] so CLI tests can assert it. + public static function log( string $msg ): void { $GLOBALS['_wp_cli_output'][] = $msg; } + public static function line( string $msg = '' ): void { $GLOBALS['_wp_cli_output'][] = $msg; } + public static function warning( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Warning: ' . $msg; } + public static function success( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Success: ' . $msg; } + public static function error( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Error: ' . $msg; } public static function add_command( string $name, $class ): void {} } } @@ -622,6 +624,7 @@ require_once TMDO_PATH . 'includes/engine/class-tmdo-query-compiler.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php'; +require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php'; require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php'; require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php'; diff --git a/tests/integration/CleanupHpTransientsTest.php b/tests/integration/CleanupHpTransientsTest.php new file mode 100644 index 0000000..ed190f7 --- /dev/null +++ b/tests/integration/CleanupHpTransientsTest.php @@ -0,0 +1,58 @@ +query( 'DROP TABLE IF EXISTS `' . self::OPTIONS . '`' ); + $wpdb->query( 'CREATE TABLE `' . self::OPTIONS . "` ( option_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, option_name VARCHAR(191) NOT NULL DEFAULT '', option_value LONGTEXT NOT NULL, autoload VARCHAR(20) NOT NULL DEFAULT 'yes', PRIMARY KEY (option_id), UNIQUE KEY option_name (option_name) ) ENGINE=InnoDB" ); + for ( $i = 1; $i <= 5; $i++ ) { + $wpdb->insert( self::OPTIONS, array( 'option_name' => "_transient_wpdo_hp_pm_{$i}_abc", 'option_value' => 'v', 'autoload' => 'no' ) ); + $wpdb->insert( self::OPTIONS, array( 'option_name' => "_transient_timeout_wpdo_hp_pm_{$i}_abc", 'option_value' => '1', 'autoload' => 'no' ) ); + } + foreach ( array( 'siteurl', '_transient_other_plugin', '_transient_wpdo_hp_pmx', 'wpdo_hp_pm_1_abc' ) as $name ) { + $wpdb->insert( self::OPTIONS, array( 'option_name' => $name, 'option_value' => 'keep' ) ); + } + $GLOBALS['_wp_cli_output'] = array(); + } + + protected function tearDown(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::OPTIONS . '`' ); + } + + private function remaining(): array { + global $wpdb; + return $wpdb->get_col( 'SELECT option_name FROM `' . self::OPTIONS . '` ORDER BY option_name' ); + } + + public function test_dry_run_counts_the_rows_and_deletes_nothing(): void { + ( new TMDO_CLI_Post() )->cleanup_hp_transients( array(), array( 'options' => true, 'dry-run' => true ) ); + + $this->assertCount( 14, $this->remaining() ); + $this->assertStringContainsString( '10 HivePress transient row(s) in wp_options', implode( "\n", $GLOBALS['_wp_cli_output'] ) ); + } + + public function test_confirm_deletes_only_the_hivepress_rows_in_batches(): void { + ( new TMDO_CLI_Post() )->cleanup_hp_transients( array(), array( 'options' => true, 'confirm' => true, 'batch' => '3' ) ); + + $this->assertSame( array( '_transient_other_plugin', '_transient_wpdo_hp_pmx', 'siteurl', 'wpdo_hp_pm_1_abc' ), $this->remaining() ); + $this->assertStringContainsString( 'Deleted 10 HivePress transient row(s) from wp_options', implode( "\n", $GLOBALS['_wp_cli_output'] ) ); + } +} diff --git a/tests/integration/CliBridgeStatusTest.php b/tests/integration/CliBridgeStatusTest.php new file mode 100644 index 0000000..ce23260 --- /dev/null +++ b/tests/integration/CliBridgeStatusTest.php @@ -0,0 +1,81 @@ +query( 'CREATE TABLE `' . self::POSTS . "` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_type VARCHAR(20) NOT NULL DEFAULT 'post', PRIMARY KEY (ID) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::POSTMETA . '` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB' ); + $wpdb->query( 'CREATE TABLE `' . self::FLAT . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, bs_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' ); + $wpdb->insert( self::POSTS, array( 'ID' => 1, 'post_type' => 'hp_listing' ) ); + $wpdb->insert( self::POSTS, array( 'ID' => 2, 'post_type' => 'post' ) ); + $wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => 'bs_price', 'meta_value' => '10' ) ); + $wpdb->insert( self::POSTMETA, array( 'post_id' => 2, 'meta_key' => 'bs_price', 'meta_value' => '20' ) ); + $wpdb->insert( self::FLAT, array( 'post_id' => 1, 'bs_price' => 10 ) ); + $wpdb->insert( self::POSTS, array( 'ID' => 3, 'post_type' => 'hp_listing' ) ); + $wpdb->insert( self::FLAT, array( 'post_id' => 3 ) ); + + $GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'post' => 'dual_write' ) ); + $GLOBALS['_wp_cli_items'] = array(); + $GLOBALS['_wp_cli_output'] = array(); + TMDO_Mode_Manager::reset_cache(); + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Entity_Registry::register_group( 'post', 'bs_grp', array( array( 'key' => 'bs_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) ); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + TMDO_Mode_Manager::reset_cache(); + } + + private static function drop(): void { + global $wpdb; + foreach ( array( self::POSTS, self::POSTMETA, self::FLAT ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } + + public function test_counts_only_flat_rows_that_hold_a_value_and_posts_of_the_groups_types(): void { + ( new TMDO_CLI_V2() )->bridge_status( array(), array() ); + + $row = array_values( array_filter( $GLOBALS['_wp_cli_items'], static fn( $r ) => 'bs_grp' === $r['group'] ) )[0]; + $this->assertSame( 1, $row['flat_rows'] ); + $this->assertSame( 1, $row['eav_ids'] ); + $this->assertSame( '100%', $row['progress_%'] ); + } +} diff --git a/tests/integration/CliMemberBackfillTest.php b/tests/integration/CliMemberBackfillTest.php new file mode 100644 index 0000000..e49030c --- /dev/null +++ b/tests/integration/CliMemberBackfillTest.php @@ -0,0 +1,85 @@ +query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::ONE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' ); + $wpdb->query( 'CREATE TABLE `' . self::TWO . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_b VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' ); + $wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" ); + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_a', 'meta_value' => 'one' ) ); + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_b', 'meta_value' => 'two' ) ); + + $GLOBALS['_wp_cache'] = array(); + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + WPDO_Entity_Registry::register_group( 'user', 'cb_one', array( array( 'key' => 'cb_a', 'type' => 'text' ) ) ); + WPDO_Entity_Registry::register_group( 'user', 'cb_two', array( array( 'key' => 'cb_b', 'type' => 'text' ) ) ); + WPDO_Schema_Manager::flush_table_exists_cache(); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + } + + private static function drop(): void { + global $wpdb; + foreach ( array( $wpdb->usermeta, self::ONE, self::TWO, self::STATUS ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } + + private function flat( string $table, string $col ): ?string { + global $wpdb; + return $wpdb->get_var( "SELECT `{$col}` FROM `{$table}` WHERE user_id = 1" ); + } + + public function test_all_backfills_every_registered_user_group(): void { + ( new TMDO_CLI_Member() )->member_backfill( array(), array( 'all' => true ) ); + + $this->assertSame( 'one', $this->flat( self::ONE, 'cb_a' ) ); + $this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) ); + } + + public function test_group_accepts_any_registered_user_group(): void { + ( new TMDO_CLI_Member() )->member_backfill( array(), array( 'group' => 'cb_two' ) ); + + $this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) ); + $this->assertNull( $this->flat( self::ONE, 'cb_a' ), 'only the requested group' ); + } + + public function test_backfillable_groups_leave_out_restored_orphans(): void { + WPDO_Entity_Registry::restore_group( 'user', 'cb_orphan', array( array( 'key' => 'cb_orphan_key', 'type' => 'text' ) ) ); + + $this->assertSame( array( 'cb_one', 'cb_two' ), TMDO_Entity_Migration_Engine::backfillable_groups( 'user' ) ); + } +} diff --git a/tests/integration/DoctorTest.php b/tests/integration/DoctorTest.php new file mode 100644 index 0000000..3a08d5f --- /dev/null +++ b/tests/integration/DoctorTest.php @@ -0,0 +1,138 @@ +query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINE=InnoDB" ); + } + + $GLOBALS['_wp_options'] = array(); + $GLOBALS['_wp_cli_output'] = array(); + TMDO_Mode_Manager::reset_cache(); + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + WPDO_Entity_Registry::register_group( 'user', 'dt_have', array( array( 'key' => 'dt_a', 'type' => 'text' ) ) ); + WPDO_Schema_Manager::flush_table_exists_cache(); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + TMDO_Mode_Manager::reset_cache(); + } + + private static function drop(): void { + global $wpdb; + foreach ( array_merge( array( self::HAVE ), self::CORE ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } + + private function cli_output(): string { + return implode( "\n", $GLOBALS['_wp_cli_output'] ?? array() ); + } + + public function test_entity_tables_come_from_the_registry(): void { + $ok = TMDO_CLI::check_entity_tables(); + + $this->assertTrue( $ok, $this->cli_output() ); + $this->assertStringContainsString( '[OK] ' . self::HAVE, $this->cli_output() ); + $this->assertStringNotContainsString( 'wpdo_user_sso', $this->cli_output() ); + } + + public function test_a_registered_group_without_its_table_is_reported(): void { + WPDO_Entity_Registry::register_group( 'user', 'dt_missing', array( array( 'key' => 'dt_b', 'type' => 'text' ) ) ); + + $ok = TMDO_CLI::check_entity_tables(); + + $this->assertFalse( $ok ); + $this->assertStringContainsString( '[MISSING] wp_itest_wpdo_user_dt_missing', $this->cli_output() ); + } + + public function test_reports_drifting_groups(): void { + $GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' ); + $GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ] = array( + 'user' => array( + 'dt_have' => array( + 'miss' => 1, + 'diff' => 2, + 'extra' => 0, + 'sample' => 0, + 'checked_at' => '2026-09-27 00:00:00', + ), + ), + ); + TMDO_Mode_Manager::reset_cache(); + + TMDO_CLI::report_guard_and_drift(); + + $this->assertStringContainsString( 'dt_have miss=1 diff=2 extra=0', $this->cli_output() ); + $this->assertStringContainsString( 'wp tmdo drift-check user --fix', $this->cli_output() ); + } + + public function test_ignores_the_record_of_a_group_no_longer_registered(): void { + $GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' ); + $GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ] = array( + 'user' => array( + 'dt_have' => array( + 'miss' => 0, + 'diff' => 0, + 'extra' => 0, + 'sample' => 0, + 'checked_at' => '2026-09-27 00:00:00', + ), + 'dt_gone' => array( + 'miss' => 3, + 'diff' => 0, + 'extra' => 0, + 'sample' => 0, + 'checked_at' => '2026-09-20 00:00:00', + ), + ), + ); + TMDO_Mode_Manager::reset_cache(); + + TMDO_CLI::report_guard_and_drift(); + + $this->assertStringContainsString( '[OK] user: 1 group(s) clean (oldest check 2026-09-27 00:00:00 UTC)', $this->cli_output() ); + $this->assertStringNotContainsString( 'dt_gone', $this->cli_output() ); + } + + public function test_reports_an_entity_never_checked(): void { + $GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' ); + TMDO_Mode_Manager::reset_cache(); + + TMDO_CLI::report_guard_and_drift(); + + $this->assertStringContainsString( 'user: never checked', $this->cli_output() ); + $this->assertStringContainsString( 'Promotion Guard: no violation', $this->cli_output() ); + } +} diff --git a/tests/integration/DriftCheckTest.php b/tests/integration/DriftCheckTest.php new file mode 100644 index 0000000..c646b4c --- /dev/null +++ b/tests/integration/DriftCheckTest.php @@ -0,0 +1,319 @@ +query( "CREATE TABLE `{$wpdb->users}` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_login VARCHAR(60) NOT NULL DEFAULT '', PRIMARY KEY (ID) ) ENGINE=InnoDB" ); + $wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, num DECIMAL(18,6) DEFAULT NULL, cnt BIGINT(20) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, prefs TEXT DEFAULT NULL, meta_json LONGTEXT DEFAULT NULL, flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' ); + + for ( $id = 1; $id <= 7; $id++ ) { + $wpdb->insert( $wpdb->users, array( 'ID' => $id, 'user_login' => "u{$id}" ) ); + } + $prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) ); + $this->native( 1, array( 'num' => '50', 'cnt' => '3', 'name' => 'a', 'prefs' => $prefs, 'meta_json' => serialize( array( 'w' => 640, 'tags' => array( 'x' ) ) ) ) ); + $this->flat( array( 'user_id' => 1, 'num' => '50.000000', 'cnt' => 3, 'name' => 'a', 'prefs' => $prefs, 'meta_json' => '{"w":640,"tags":["x"]}' ) ); + $this->native( 2, array( 'name' => 'b', 'cnt' => '5' ) ); + $this->flat( array( 'user_id' => 2, 'name' => 'B', 'cnt' => 6 ) ); + $this->native( 3, array( 'name' => 'c' ) ); + $this->flat( array( 'user_id' => 3, 'cnt' => null ) ); + $this->flat( array( 'user_id' => 4, 'cnt' => 9 ) ); + $this->native( 5, array( 'name' => '' ) ); + $this->flat( array( 'user_id' => 5 ) ); + $this->native( 6, array( 'name' => str_repeat( 'x', 300 ) ) ); + $this->flat( array( 'user_id' => 6 ) ); + $this->native( 7, array( 'cnt' => '7' ) ); + + $GLOBALS['_wp_options'] = array(); + $GLOBALS['_wp_cache'] = array(); + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + WPDO_Entity_Registry::register_group( + 'user', + 'dc_grp', + array( + array( 'key' => 'num', 'type' => 'decimal' ), + array( 'key' => 'cnt', 'type' => 'integer' ), + array( 'key' => 'name', 'type' => 'text' ), + array( 'key' => 'prefs', 'type' => 'textarea' ), + array( 'key' => 'meta_json', 'type' => 'json' ), + array( 'key' => 'flag', 'type' => 'integer', 'default' => 0, 'required' => true ), + ) + ); + WPDO_Schema_Manager::flush_table_exists_cache(); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + } + + private static function drop(): void { + global $wpdb; + foreach ( array( $wpdb->users, $wpdb->usermeta, self::TABLE ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } + + private function native( int $user_id, array $values ): void { + global $wpdb; + foreach ( $values as $key => $value ) { + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) ); + } + } + + private function flat( array $row ): void { + global $wpdb; + $wpdb->insert( self::TABLE, $row ); + } + + private function snapshot(): string { + global $wpdb; + return md5( wp_json_encode( array( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ), $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) ); + } + + public function test_counts_miss_diff_and_extra_per_key(): void { + $report = TMDO_Drift_Check::run( 'user' ); + + $this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] ); + $keys = $report['groups']['dc_grp']['keys']; + $this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 0 ), $keys['name'] ); + $this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 1 ), $keys['cnt'] ); + foreach ( array( 'num', 'prefs', 'meta_json', 'flag' ) as $key ) { + $this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $keys[ $key ], "{$key}: equal by value" ); + } + } + + public function test_records_the_outcome_per_group(): void { + TMDO_Drift_Check::run( 'user', 'dc_grp' ); + + $status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']; + $this->assertSame( 2, $status['miss'] ); + $this->assertSame( 2, $status['diff'] ); + $this->assertSame( 1, $status['extra'] ); + $this->assertSame( 0, $status['sample'] ); + $this->assertNotEmpty( $status['checked_at'] ); + } + + public function test_a_sample_covering_every_user_matches_the_full_check(): void { + $report = TMDO_Drift_Check::run( 'user', '', 100 ); + + $this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] ); + $this->assertSame( 100, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['sample'] ); + } + + public function test_never_writes_native_or_flat(): void { + $before = $this->snapshot(); + + TMDO_Drift_Check::run( 'user' ); + + $this->assertSame( $before, $this->snapshot() ); + } + + // ── fix() (2.2 PR-071) ────────────────────────────────────────────────── + + private function flat_row( int $user_id ): ?array { + global $wpdb; + return $wpdb->get_row( $wpdb->prepare( 'SELECT num, cnt, name, flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A ); + } + + public function test_fix_rebuilds_drifted_rows_from_native_and_leaves_no_drift(): void { + $result = TMDO_Drift_Check::fix( 'user' ); + + $this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $result['after']['total'] ); + $this->assertSame( 'b', $this->flat_row( 2 )['name'] ); + $this->assertSame( '5', $this->flat_row( 2 )['cnt'] ); + $this->assertSame( 'c', $this->flat_row( 3 )['name'] ); + $this->assertSame( array( 'num' => null, 'cnt' => '7', 'name' => null, 'flag' => '0' ), $this->flat_row( 7 ), 'new row: no invented defaults' ); + $this->assertSame( array( 'num' => null, 'cnt' => null, 'name' => null, 'flag' => '0' ), $this->flat_row( 4 ), 'no native values: row kept, columns cleared' ); + $this->assertSame( 0, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['diff'] ); + } + + public function test_fix_deletes_rows_of_entities_that_no_longer_exist(): void { + $this->flat( array( 'user_id' => 99, 'name' => 'ghost' ) ); + + $result = TMDO_Drift_Check::fix( 'user' ); + + $this->assertSame( 1, $result['fixed']['deleted_rows'] ); + $this->assertNull( $this->flat_row( 99 ) ); + $this->assertNotNull( $this->flat_row( 1 ), 'rows of existing users stay' ); + } + + public function test_fix_never_writes_native_and_invalidates_the_row_cache(): void { + global $wpdb; + $native = md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) ); + TMDO_Cache_Orchestrator::set_row( 'user', 2, 'dc_grp', array( 'name' => 'B' ) ); + + TMDO_Drift_Check::fix( 'user' ); + + $this->assertSame( $native, md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) ) ); + $this->assertFalse( TMDO_Cache_Orchestrator::get_row( 'user', 2, 'dc_grp' ) ); + } + + // ── cron_tick() (2.2 PR-073) ──────────────────────────────────────────── + + private static function set_modes( ?array $modes ): void { + $cache = ( new ReflectionClass( WPDO_Mode_Manager::class ) )->getProperty( 'cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, $modes ); + } + + public function test_the_daily_tick_samples_entities_that_write_to_flat(): void { + self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) ); + try { + TMDO_Drift_Check::cron_tick(); + } finally { + self::set_modes( null ); + } + + $status = get_option( TMDO_Drift_Check::OPTION ); + $this->assertSame( TMDO_Drift_Check::CRON_SAMPLE, $status['user']['dc_grp']['sample'] ); + $this->assertSame( 2, $status['user']['dc_grp']['diff'], 'the sample covers all 7 fixture users' ); + $this->assertArrayNotHasKey( 'post', $status ); + } + + public function test_the_daily_tick_skips_entities_that_do_not_write_to_flat(): void { + self::set_modes( array( 'post' => 'disabled', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); + try { + TMDO_Drift_Check::cron_tick(); + } finally { + self::set_modes( null ); + } + + $this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) ); + } + + public function test_the_daily_tick_skips_aeav_only_where_flat_is_the_source_of_truth(): void { + self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) ); + try { + TMDO_Drift_Check::cron_tick(); + } finally { + self::set_modes( null ); + } + + $this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) ); + } + + public function test_fix_refuses_aeav_only_and_leaves_flat_alone(): void { + global $wpdb; + $flat = md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ); + self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) ); + try { + TMDO_Drift_Check::fix( 'user' ); + $this->fail( 'fix() must refuse aeav_only' ); + } catch ( RuntimeException $e ) { + $this->assertStringContainsString( 'aeav_only', $e->getMessage() ); + } finally { + self::set_modes( null ); + } + + $this->assertSame( $flat, md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) ); + } + + // ── resync after writes bypassed flat (2.2 PR-074) ────────────────────── + + public function test_turning_writes_on_schedules_a_fix_for_that_entity(): void { + $this->assertSame( array( 'user' ), TMDO_Drift_Check::resync_after_mode_change( 'user', 'dual_write', 'disabled' ) ); + } + + public function test_other_mode_changes_schedule_nothing(): void { + $this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'shadow_read', 'dual_write' ) ); + $this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'aeav_only', 'shadow_read' ) ); + $this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'disabled', 'dual_write' ) ); + } + + public function test_turning_the_hook_bus_on_schedules_a_fix_for_entities_that_keep_native(): void { + self::set_modes( array( 'post' => 'aeav_only', 'user' => 'dual_write', 'term' => 'shadow_read', 'comment' => 'disabled' ) ); + try { + $on = TMDO_Drift_Check::resync_after_hook_bus_change( '0', '1' ); + $off = TMDO_Drift_Check::resync_after_hook_bus_change( '1', '0' ); + } finally { + self::set_modes( null ); + } + + $this->assertSame( array( 'user', 'term' ), $on ); + $this->assertSame( array(), $off ); + } + + public function test_the_scheduled_fix_repairs_the_entity(): void { + self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) ); + try { + TMDO_Drift_Check::run_scheduled_fix( 'user' ); + } finally { + self::set_modes( null ); + } + + $status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']; + $this->assertSame( 0, $status['miss'] + $status['diff'] + $status['extra'] ); + } + + public function test_the_scheduled_fix_leaves_aeav_only_alone(): void { + self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) ); + try { + TMDO_Drift_Check::run_scheduled_fix( 'user' ); + } finally { + self::set_modes( null ); + } + + $this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) ); + $this->assertSame( 'B', $this->flat_row( 2 )['name'], 'flat untouched' ); + } + + public function test_the_resync_triggers_are_hooked(): void { + $source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' ); + + foreach ( array( + "add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );", + "add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );", + "add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );", + "add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );", + ) as $line ) { + $this->assertStringContainsString( $line, $source ); + } + // Literal: uninstall.php does not load TMDO_Drift_Check. + $installer = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php' ); + $this->assertSame( 2, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'cleared on deactivate and uninstall' ); + } + + public function test_the_daily_tick_runs_on_the_daily_health_check_hook(): void { + $source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' ); + + $this->assertStringContainsString( "add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );", $source ); + } + + public function test_rejects_an_unknown_group(): void { + $this->expectException( InvalidArgumentException::class ); + TMDO_Drift_Check::run( 'user', 'no_such_group' ); + } +} diff --git a/tests/integration/EntityMigrationEngineDefaultsTest.php b/tests/integration/EntityMigrationEngineDefaultsTest.php new file mode 100644 index 0000000..a606828 --- /dev/null +++ b/tests/integration/EntityMigrationEngineDefaultsTest.php @@ -0,0 +1,139 @@ +query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, login_count BIGINT(20) DEFAULT 0, session_count BIGINT(20) DEFAULT 0, note VARCHAR(255) DEFAULT NULL, req_flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' ); + $wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" ); + + $GLOBALS['_wp_cache'] = array(); + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + WPDO_Entity_Registry::register_group( + 'user', + 'md_act', + array( + array( 'key' => 'login_count', 'type' => 'integer', 'default' => 0 ), + array( 'key' => 'session_count', 'type' => 'integer', 'default' => 0 ), + array( 'key' => 'note', 'type' => 'text' ), + array( 'key' => 'req_flag', 'type' => 'integer', 'default' => 0, 'required' => true ), + ) + ); + WPDO_Schema_Manager::flush_table_exists_cache(); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + } + + private static function drop(): void { + global $wpdb; + foreach ( array( $wpdb->usermeta, self::TABLE, self::STATUS ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } + + private function native( int $user_id, string $key, string $value ): void { + global $wpdb; + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) ); + } + + /** @return array */ + private function flat_row( int $user_id ): array { + global $wpdb; + return (array) $wpdb->get_row( $wpdb->prepare( 'SELECT login_count, session_count, note, req_flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A ); + } + + private function migrate(): array { + return TMDO_Entity_Migration_Engine::migrate_group( 'user', 'md_act', array( 'resume' => false, 'sleep_ms' => 0 ) ); + } + + public function test_keys_without_a_native_row_stay_null_instead_of_the_column_default(): void { + $this->native( 1, 'login_count', '7' ); + + $report = $this->migrate(); + + $this->assertSame( 1, $report['migrated'] ); + $row = $this->flat_row( 1 ); + $this->assertSame( '7', $row['login_count'] ); + $this->assertNull( $row['session_count'], 'no native session_count — must not become the DEFAULT 0' ); + $this->assertNull( $row['note'] ); + } + + public function test_a_native_value_equal_to_the_default_is_still_written(): void { + $this->native( 1, 'session_count', '0' ); + + $this->migrate(); + + $this->assertSame( '0', $this->flat_row( 1 )['session_count'] ); + } + + public function test_a_stale_flat_value_is_cleared_when_the_native_row_is_gone(): void { + global $wpdb; + $wpdb->insert( self::TABLE, array( 'user_id' => 1, 'login_count' => 3, 'session_count' => 5, 'note' => 'old' ) ); + $this->native( 1, 'login_count', '4' ); + + $this->migrate(); + + $row = $this->flat_row( 1 ); + $this->assertSame( '4', $row['login_count'] ); + $this->assertNull( $row['session_count'] ); + $this->assertNull( $row['note'] ); + } + + public function test_a_required_column_without_a_native_row_keeps_its_default(): void { + $this->native( 1, 'login_count', '1' ); + + $report = $this->migrate(); + + $this->assertSame( 0, $report['errors'] ); + $this->assertSame( '0', $this->flat_row( 1 )['req_flag'], 'NOT NULL column cannot hold NULL — its default is the documented stand-in' ); + } + + public function test_the_cron_batch_path_does_not_invent_defaults_either(): void { + $this->native( 1, 'login_count', '2' ); + + $result = TMDO_Entity_Migration_Engine::migrate_group_batch( 'user', 'md_act' ); + + $this->assertSame( 1, $result['migrated'] ); + $this->assertNull( $this->flat_row( 1 )['session_count'] ); + } +} diff --git a/tests/integration/PostBackfillJsonTest.php b/tests/integration/PostBackfillJsonTest.php index 4a6bf39..d87d099 100644 --- a/tests/integration/PostBackfillJsonTest.php +++ b/tests/integration/PostBackfillJsonTest.php @@ -215,6 +215,38 @@ class PostBackfillJsonTest extends TestCase { $this->assertSame( 0, $result['errors'] ?? -1 ); } + // ── backfill_group() / backfillable_groups() (2.2 PR-067) ─────────────── + + public function test_backfill_group_fills_json_columns_instead_of_skipping_them(): void { + $this->seed_attachment( 1, array( 'width' => 640 ), 'Alt text' ); + + $result = WPDO_Post_Migration::backfill_group( 'attachment' ); + + $this->assertSame( 'attachment', $result['group'] ); + global $wpdb; + $row = $wpdb->get_row( 'SELECT _wp_attachment_metadata, _wp_attachment_image_alt FROM `' . self::FLAT . '` WHERE post_id = 1', ARRAY_A ); + $this->assertSame( array( 'width' => 640 ), json_decode( (string) $row['_wp_attachment_metadata'], true ), 'json field must not be left NULL by the bulk pivot' ); + $this->assertSame( 'Alt text', $row['_wp_attachment_image_alt'] ); + } + + public function test_backfillable_groups_are_the_registered_post_groups(): void { + WPDO_Entity_Registry::register_group( 'post', 'itest_extra', array( array( 'key' => 'itest_extra_key', 'type' => 'text' ) ) ); + + $groups = WPDO_Post_Migration::backfillable_groups(); + + $this->assertSame( WPDO_Entity_Registry::get_groups_for_type( 'post' ), $groups ); + $this->assertContains( 'itest_extra', $groups, 'a group registered by an add-on must be offered for backfill' ); + } + + public function test_restored_orphan_groups_are_never_backfilled(): void { + WPDO_Entity_Registry::restore_group( 'post', 'itest_orphan', array( array( 'key' => 'itest_orphan_key', 'type' => 'text' ) ) ); + $this->assertContains( 'itest_orphan', WPDO_Entity_Registry::get_groups_for_type( 'post' ) ); + + $this->assertNotContains( 'itest_orphan', WPDO_Post_Migration::backfillable_groups(), 'orphan data lives only in the flat table' ); + $this->expectException( InvalidArgumentException::class ); + WPDO_Post_Migration::backfill_group( 'itest_orphan' ); + } + public function test_returns_error_for_unknown_group(): void { $result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' ); diff --git a/tests/integration/PostEntityLifecycleTest.php b/tests/integration/PostEntityLifecycleTest.php index 097c951..b074999 100644 --- a/tests/integration/PostEntityLifecycleTest.php +++ b/tests/integration/PostEntityLifecycleTest.php @@ -129,6 +129,7 @@ class PostEntityLifecycleTest extends TestCase { WPDO_Entity_Registry::init(); WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); WPDO_Post_Fields::register_entity_fields(); + tmdo_test_register_wc_product_group(); } public static function tearDownAfterClass(): void { diff --git a/tests/integration/PostMigrationTest.php b/tests/integration/PostMigrationTest.php index 164a118..d8eb3ff 100644 --- a/tests/integration/PostMigrationTest.php +++ b/tests/integration/PostMigrationTest.php @@ -93,6 +93,7 @@ class PostMigrationTest extends TestCase { WPDO_Entity_Registry::init(); WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); WPDO_Post_Fields::register_entity_fields(); + tmdo_test_register_wc_product_group(); } public static function tearDownAfterClass(): void { diff --git a/tests/integration/PostQueryRouterDriftTest.php b/tests/integration/PostQueryRouterDriftTest.php new file mode 100644 index 0000000..a113202 --- /dev/null +++ b/tests/integration/PostQueryRouterDriftTest.php @@ -0,0 +1,133 @@ + 'shadow_read', + 'user' => 'disabled', + 'term' => 'disabled', + 'comment' => 'disabled', + ) + ); + } + + private static function set_modes( ?array $modes ): void { + $cache = ( new ReflectionClass( WPDO_Mode_Manager::class ) )->getProperty( 'cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, $modes ); + } + + private function drift( string $group, int $diff ): void { + $GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ]['post'][ $group ] = array( + 'miss' => 0, + 'diff' => $diff, + 'extra' => 0, + 'sample' => 0, + 'checked_at' => '2026-09-27 00:00:00', + ); + } + + private function price_query( array $extra = array() ): WP_Query { + $query = new WP_Query(); + foreach ( array( + 'post_type' => 'product', + 'meta_query' => array( + array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ), + ), + ) + $extra as $k => $v ) { + $query->set( $k, $v ); + } + return $query; + } + + public function test_a_drifting_group_keeps_the_query_native(): void { + $this->drift( 'wc_product', 3 ); + $query = $this->price_query(); + $original = $query->get( 'meta_query' ); + + ( new WPDO_Post_Query_Router() )->pre_get_posts( $query ); + + $this->assertSame( $original, $query->get( 'meta_query' ) ); + $this->assertSame( '', $query->get( 'wpdo_post_clauses' ) ); + } + + public function test_a_clean_group_is_routed(): void { + $this->drift( 'wc_product', 0 ); + $query = $this->price_query(); + + ( new WPDO_Post_Query_Router() )->pre_get_posts( $query ); + + $this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) ); + } + + public function test_a_group_never_checked_is_routed(): void { + $query = $this->price_query(); + + ( new WPDO_Post_Query_Router() )->pre_get_posts( $query ); + + $this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) ); + } + + public function test_drift_in_another_group_does_not_matter(): void { + $this->drift( 'hp_listing_core', 5 ); + $query = $this->price_query(); + + ( new WPDO_Post_Query_Router() )->pre_get_posts( $query ); + + $this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) ); + } + + public function test_aeav_only_ignores_drift_because_native_is_stale_by_design(): void { + self::set_modes( + array( + 'post' => 'aeav_only', + 'user' => 'disabled', + 'term' => 'disabled', + 'comment' => 'disabled', + ) + ); + $this->drift( 'wc_product', 3 ); + $query = $this->price_query(); + + ( new WPDO_Post_Query_Router() )->pre_get_posts( $query ); + + $this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) ); + } + + public function test_sorting_by_a_drifting_key_stays_native(): void { + $this->drift( 'wc_product', 1 ); + $query = new WP_Query(); + $query->set( 'post_type', 'product' ); + $query->set( 'meta_key', '_price' ); + $query->set( 'orderby', 'meta_value_num' ); + + ( new WPDO_Post_Query_Router() )->pre_get_posts( $query ); + + $this->assertSame( '_price', $query->get( 'meta_key' ) ); + $this->assertSame( '', $query->get( 'wpdo_post_sort' ) ); + } +} diff --git a/tests/integration/PostQueryRouterOrderTest.php b/tests/integration/PostQueryRouterOrderTest.php index 5653581..b9a1198 100644 --- a/tests/integration/PostQueryRouterOrderTest.php +++ b/tests/integration/PostQueryRouterOrderTest.php @@ -20,6 +20,7 @@ class PostQueryRouterOrderTest extends TestCase { WPDO_Entity_Registry::init(); WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); WPDO_Post_Fields::register_entity_fields(); + tmdo_test_register_wc_product_group(); } protected function setUp(): void { diff --git a/tests/integration/PostQueryRouterTest.php b/tests/integration/PostQueryRouterTest.php index 9c16205..2f82d5b 100644 --- a/tests/integration/PostQueryRouterTest.php +++ b/tests/integration/PostQueryRouterTest.php @@ -39,6 +39,7 @@ class PostQueryRouterTest extends TestCase { WPDO_Entity_Registry::init(); WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); WPDO_Post_Fields::register_entity_fields(); + tmdo_test_register_wc_product_group(); } public static function tearDownAfterClass(): void { diff --git a/tests/integration/ShadowDiffThrottleTest.php b/tests/integration/ShadowDiffThrottleTest.php new file mode 100644 index 0000000..85259be --- /dev/null +++ b/tests/integration/ShadowDiffThrottleTest.php @@ -0,0 +1,89 @@ +query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::DIFFS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ts DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', entity_type VARCHAR(20) NOT NULL DEFAULT '', entity_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) NOT NULL DEFAULT '', postmeta_value LONGTEXT, zone_value LONGTEXT, diff_hash VARCHAR(40) NOT NULL DEFAULT '', PRIMARY KEY (id) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::ERRORS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, module VARCHAR(100) NOT NULL DEFAULT '', zone VARCHAR(20) NOT NULL DEFAULT '', hook VARCHAR(255) NOT NULL DEFAULT '', message TEXT NOT NULL, context LONGTEXT, created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id) ) ENGINE=InnoDB" ); + for ( $id = 1; $id <= 30; $id++ ) { + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'total_sales', 'meta_value' => '5' ) ); + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'other_key', 'meta_value' => '5' ) ); + } + + $GLOBALS['_wp_transients'] = array(); + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + } + + private static function drop(): void { + global $wpdb; + foreach ( array( $wpdb->usermeta, self::DIFFS, self::ERRORS ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } + + private function logged( string $key ): int { + global $wpdb; + return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM `' . self::DIFFS . '` WHERE meta_key = %s', $key ) ); + } + + private function stale_read( int $user_id, string $key ): void { + TMDO_Shadow_Diff_Logger::compare_and_log( 'user', $user_id, $key, 4, array( 'type' => 'integer' ) ); + } + + public function test_a_key_stale_on_every_object_logs_at_most_the_key_limit(): void { + for ( $id = 1; $id <= 30; $id++ ) { + $this->stale_read( $id, 'total_sales' ); + } + + $this->assertSame( TMDO_Shadow_Diff_Logger::KEY_LIMIT, $this->logged( 'total_sales' ) ); + } + + public function test_the_limit_is_per_key(): void { + for ( $id = 1; $id <= 30; $id++ ) { + $this->stale_read( $id, 'total_sales' ); + } + $this->stale_read( 1, 'other_key' ); + + $this->assertSame( 1, $this->logged( 'other_key' ) ); + } + + public function test_the_same_object_and_key_still_log_once_per_window(): void { + $this->stale_read( 1, 'total_sales' ); + $this->stale_read( 1, 'total_sales' ); + + $this->assertSame( 1, $this->logged( 'total_sales' ) ); + } +} diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 1953369..6d3dad8 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -517,10 +517,12 @@ if ( ! function_exists( 'wp_insert_comment' ) ) { if ( ! class_exists( 'WP_CLI' ) ) { class WP_CLI { - public static function log( string $msg ): void {} - public static function warning( string $msg ): void {} - public static function success( string $msg ): void {} - public static function error( string $msg ): void {} + // Output is kept in $GLOBALS['_wp_cli_output'] so CLI tests can assert it. + public static function log( string $msg ): void { $GLOBALS['_wp_cli_output'][] = $msg; } + public static function line( string $msg = '' ): void { $GLOBALS['_wp_cli_output'][] = $msg; } + public static function warning( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Warning: ' . $msg; } + public static function success( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Success: ' . $msg; } + public static function error( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Error: ' . $msg; } public static function add_command( string $name, $class ): void {} } } @@ -586,6 +588,7 @@ require_once TMDO_PATH . 'includes/engine/class-tmdo-query-compiler.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php'; +require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php'; @@ -646,3 +649,43 @@ if ( ! class_exists( 'TMDO_Listing_Stats' ) ) { } class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' ); } + +/** + * The WooCommerce product group core registered before 1.2.0 (PR-077 moved + * product meta back to native only). Tests that use products as a routed + * post type register it themselves; the table name wp_itest_wpdo_post_wc_product + * is unchanged. + */ +function tmdo_test_register_wc_product_group(): void { + $enum = static fn( string $key, array $options ): array => array( + 'key' => $key, + 'type' => 'enum', + 'options' => $options, + ); + TMDO_Entity_Registry::register_group( + 'post', + 'wc_product', + array( + array( 'key' => '_price', 'type' => 'decimal', 'searchable' => true ), + array( 'key' => '_regular_price', 'type' => 'decimal', 'searchable' => true ), + array( 'key' => '_sale_price', 'type' => 'decimal', 'searchable' => true ), + array( 'key' => '_stock', 'type' => 'integer', 'searchable' => true ), + array( 'key' => '_stock_status', 'type' => 'enum', 'searchable' => true, 'options' => array( 'instock', 'outofstock', 'onbackorder' ) ), + array( 'key' => '_sku', 'type' => 'text', 'searchable' => true ), + $enum( '_manage_stock', array( 'yes', 'no' ) ), + $enum( '_backorders', array( 'yes', 'no', 'notify' ) ), + $enum( '_sold_individually', array( 'yes', 'no' ) ), + $enum( '_virtual', array( 'yes', 'no' ) ), + $enum( '_downloadable', array( 'yes', 'no' ) ), + array( 'key' => '_tax_class', 'type' => 'text' ), + $enum( '_tax_status', array( 'taxable', 'shipping', 'none' ) ), + array( 'key' => '_download_limit', 'type' => 'integer' ), + array( 'key' => '_download_expiry', 'type' => 'integer' ), + array( 'key' => '_product_version', 'type' => 'text' ), + array( 'key' => '_wc_average_rating', 'type' => 'decimal', 'searchable' => true ), + array( 'key' => '_wc_review_count', 'type' => 'integer' ), + array( 'key' => 'total_sales', 'type' => 'integer', 'searchable' => true ), + ), + array( 'post_types' => array( 'product' ) ) + ); +} diff --git a/tests/live/README.md b/tests/live/README.md index 602d5e3..2591e93 100644 --- a/tests/live/README.md +++ b/tests/live/README.md @@ -41,6 +41,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test: | `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) | | `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route, meta orderby: HivePress sort shape, named clause, meta_key + meta_value_num); routed SQL has no GROUP BY | | `rest` | anonymous GET of the public listings endpoints returns no protected or credential-like field (1.1.1) | +| `drift` | `TMDO_Drift_Check`: json groups show no false diff; a planted flat mismatch / flat-only value counts exactly once (1.2.0) | | `zero_side_effect` | zone tables that were never created are never queried on save / delete | ## Tools (`tools/`) diff --git a/tests/live/suites/drift.php b/tests/live/suites/drift.php new file mode 100644 index 0000000..9db544d --- /dev/null +++ b/tests/live/suites/drift.php @@ -0,0 +1,77 @@ + Report. + */ +function tmdo_live_drift( string $entity, string $group ): array { + $status = get_option( TMDO_Drift_Check::OPTION, null ); + try { + return TMDO_Drift_Check::run( $entity, $group ); + } finally { + null === $status ? delete_option( TMDO_Drift_Check::OPTION ) : update_option( TMDO_Drift_Check::OPTION, $status ); + } +} + +TMDO_Live::test( + 'drift-check reports no diff for json groups whose values match native', + static function () { + $checked = 0; + foreach ( array( 'attachment', 'nav_menu_item' ) as $group ) { + if ( ! in_array( $group, TMDO_Entity_Migration_Engine::backfillable_groups( 'post' ), true ) + || ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( 'post', $group ) ) ) { + continue; + } + $report = tmdo_live_drift( 'post', $group ); + tmdo_live_assert_same( 0, $report['total']['diff'], "post/{$group} diff" ); + ++$checked; + } + if ( 0 === $checked ) { + throw new TMDO_Live_Skip( 'no attachment / nav_menu_item flat table on this site' ); + } + } +); + +TMDO_Live::test( + 'drift-check counts a planted flat mismatch once and a flat-only value once', + static function () { + global $wpdb; + TMDO_Live_Fixtures::use_modes( array( 'user' => 'disabled' ) ); + $field = TMDO_Live_Fixtures::field( 'user', 'login_count' ); + $group = $field['group']; + $table = TMDO_Schema_Manager::get_table_name( 'user', $group ); + + $before = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count']; + + $mismatch = TMDO_Live_Fixtures::user(); + $flat_only = TMDO_Live_Fixtures::user(); + add_user_meta( $mismatch, 'login_count', '5' ); + try { + $wpdb->insert( $table, array( 'user_id' => $mismatch, 'login_count' => 6 ) ); + $wpdb->insert( $table, array( 'user_id' => $flat_only, 'login_count' => 9 ) ); + + $after = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count']; + } finally { + $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE user_id IN (%d, %d)", $mismatch, $flat_only ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + tmdo_live_assert_same( $before['diff'] + 1, $after['diff'], 'login_count diff +1' ); + tmdo_live_assert_same( $before['extra'] + 1, $after['extra'], 'login_count extra +1' ); + tmdo_live_assert_same( $before['miss'], $after['miss'], 'login_count miss unchanged' ); + } +); diff --git a/tests/live/suites/router.php b/tests/live/suites/router.php index 0ce0a6e..f57f83f 100644 --- a/tests/live/suites/router.php +++ b/tests/live/suites/router.php @@ -247,21 +247,22 @@ TMDO_Live::test( TMDO_Live::test( 'Q06 LIKE matches substrings and escapes wildcards, routed', static function () { - TMDO_Live_Fixtures::field( 'post', '_sku', 'product' ); + // A text key of wp_core (WooCommerce's _sku left the flat tables in 1.2.0). + TMDO_Live_Fixtures::field( 'post', '_wp_page_template' ); TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); $p = tmdo_live_router_posts( - 'product', + 'post', array( - 'P1' => array( '_sku' => 'tmdo-live-B1-1' ), - 'P2' => array( '_sku' => 'tmdo-live-B1-10' ), - 'P3' => array( '_sku' => 'tmdo-live-B2-1' ), + 'P1' => array( '_wp_page_template' => 'tmdo-live-B1-1' ), + 'P2' => array( '_wp_page_template' => 'tmdo-live-B1-10' ), + 'P3' => array( '_wp_page_template' => 'tmdo-live-B2-1' ), ) ); foreach ( array( 'B1-1', 'B1_1' ) as $needle ) { tmdo_live_router_assert_native( array( - 'post_type' => 'product', - 'meta_query' => array( array( 'key' => '_sku', 'value' => $needle, 'compare' => 'LIKE' ) ), + 'post_type' => 'post', + 'meta_query' => array( array( 'key' => '_wp_page_template', 'value' => $needle, 'compare' => 'LIKE' ) ), ), array_values( $p ), true @@ -423,3 +424,160 @@ TMDO_Live::test( tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request ); } ); + +/** + * HivePress-style sort-only search (rating__order), fields ids, first page. + * DECIMAL(10,1): what the HivePress Rating field (decimals 1) sends. + * + * @param string $order ASC | DESC. + * @return array WP_Query args. + */ +function tmdo_live_router_rating_sort( string $order ): array { + return array( + 'post_type' => 'hp_listing', + 'post_status' => 'publish', + 'posts_per_page' => 10, + 'fields' => 'ids', + 'suppress_filters' => false, + 'meta_query' => array( + array( + 'relation' => 'OR', + 'rating__order' => array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'NOT EXISTS' ), + array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'EXISTS' ), + ), + ), + 'orderby' => 'rating__order', + 'order' => $order, + ); +} + +/** + * The native sort key of each post, in order (null without a rating). On the + * whole site many listings share a rating and native orders ties by chance, + * so a page is compared by its keys, not its ids. + * + * @param array $ids Post ids. + * @return array + */ +function tmdo_live_router_rating_keys( array $ids ): array { + global $wpdb; + $ids = array_map( 'intval', $ids ); + if ( empty( $ids ) ) { + return array(); + } + $rows = $wpdb->get_results( + "SELECT post_id, CAST(meta_value AS DECIMAL(10,1)) AS k FROM {$wpdb->postmeta} WHERE meta_key = 'hp_rating' AND post_id IN (" . implode( ',', $ids ) . ')', + OBJECT_K + ); + return array_map( static fn( $id ) => isset( $rows[ $id ] ) ? (string) $rows[ $id ]->k : null, $ids ); +} + +/** + * Native and routed runs of the same query. + * + * @param array $args Query args. + * @return array{0:WP_Query, 1:WP_Query} + */ +function tmdo_live_router_native_and_routed( array $args ): array { + $native = new WP_Query( $args ); + $router = new TMDO_Post_Query_Router(); + $router->register_hooks(); + try { + $routed = new WP_Query( $args ); + } finally { + tmdo_live_router_unhook( $router ); + } + return array( $native, $routed ); +} + +TMDO_Live::test( + 'a sort-only search drives from the flat table when every post of the type has a flat row (2.2 PR-079)', + static function () { + TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + TMDO_Post_Query_Router::forget_coverage(); + foreach ( array( 'DESC', 'ASC' ) as $order ) { + list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( $order ) ); + tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), "{$order} sort keys" ); + tmdo_live_assert_same( $native->found_posts, $routed->found_posts, "{$order} found_posts" ); + tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), "{$order} flat-first: " . $routed->request ); + } + } +); + +TMDO_Live::test( + 'a sort-only search keeps the LEFT JOIN while a post of the type has no flat row (2.2 PR-079)', + static function () { + global $wpdb; + TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + $bare = TMDO_Live_Fixtures::post( 'hp_listing' ); + $wpdb->delete( TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' ), array( 'post_id' => $bare ) ); + TMDO_Post_Query_Router::forget_coverage(); + try { + list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( 'DESC' ) ); + } finally { + TMDO_Post_Query_Router::forget_coverage(); + } + tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' ); + tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' ); + tmdo_live_assert_true( false === strpos( $routed->request, 'STRAIGHT_JOIN' ), 'not flat-first: ' . $routed->request ); + } +); + +TMDO_Live::test( + 'a post created while the router is active gets its empty flat row (2.2 PR-079)', + static function () { + global $wpdb; + TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + $router = new TMDO_Post_Query_Router(); + $router->register_hooks(); + try { + $id = TMDO_Live_Fixtures::post( 'hp_listing' ); + } finally { + tmdo_live_router_unhook( $router ); + } + $table = TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' ); + tmdo_live_assert_same( '1', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE post_id = %d", $id ) ), 'flat row created' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } +); + +TMDO_Live::test( + 'a flat-first search drops the GROUP BY WordPress adds for a subquery-only tax query (2.2 PR-079)', + static function () { + TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + TMDO_Post_Query_Router::forget_coverage(); + // Like WooCommerce's product_visibility exclusion on front-end searches: NOT IN + // is a subquery, yet WordPress groups by posts.ID for any tax query. + $args = tmdo_live_router_rating_sort( 'DESC' ) + array( + 'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => array( 999999999 ), 'operator' => 'NOT IN' ) ), + ); + list( $native, $routed ) = tmdo_live_router_native_and_routed( $args ); + tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' ); + tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' ); + tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request ); + tmdo_live_assert_true( false === stripos( $routed->request, 'GROUP BY' ), 'no GROUP BY: ' . $routed->request ); + } +); + +TMDO_Live::test( + 'a flat-first search keeps the GROUP BY when a tax query joins term relationships (2.2 PR-079)', + static function () { + TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + TMDO_Post_Query_Router::forget_coverage(); + $terms = get_terms( array( 'taxonomy' => 'hp_listing_category', 'number' => 2, 'hide_empty' => true, 'fields' => 'ids' ) ); + if ( count( (array) $terms ) < 2 ) { + throw new TMDO_Live_Skip( 'needs two used hp_listing_category terms' ); + } + $args = tmdo_live_router_rating_sort( 'DESC' ) + array( + 'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => $terms, 'operator' => 'IN' ) ), + ); + list( $native, $routed ) = tmdo_live_router_native_and_routed( $args ); + tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' ); + tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' ); + tmdo_live_assert_true( false !== stripos( $routed->request, 'GROUP BY' ), 'GROUP BY kept: ' . $routed->request ); + } +); diff --git a/tests/unit/AutoPromoterDriftTest.php b/tests/unit/AutoPromoterDriftTest.php new file mode 100644 index 0000000..aa7e42e --- /dev/null +++ b/tests/unit/AutoPromoterDriftTest.php @@ -0,0 +1,78 @@ + array( 'user' => 'shadow_read' ) ); + $GLOBALS['_wp_filter_callbacks'] = array(); + $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true ); + TMDO_Mode_Manager::reset_cache(); + TMDO_Entity_Registry::init(); + TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() ); + TMDO_Entity_Registry::register_group( 'user', 'ap_one', array( array( 'key' => 'ap_a', 'type' => 'text' ) ) ); + TMDO_Entity_Registry::register_group( 'user', 'ap_two', array( array( 'key' => 'ap_b', 'type' => 'text' ) ) ); + } + + protected function tearDown(): void { + TMDO_Entity_Registry::init(); + TMDO_Mode_Manager::reset_cache(); + } + + private function drift( string $group, int $diff, int $age = 0 ): void { + $GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ]['user'][ $group ] = array( + 'miss' => 0, + 'diff' => $diff, + 'extra' => 0, + 'sample' => TMDO_Drift_Check::CRON_SAMPLE, + 'checked_at' => gmdate( 'Y-m-d H:i:s', time() - $age ), + ); + } + + private function check(): string { + return TMDO_Auto_Promoter::check_entity( 'user', 7, array( 'user' => time() - self::TEN_DAYS_AGO ) ); + } + + public function test_promotes_when_every_group_checked_clean_recently(): void { + $this->drift( 'ap_one', 0 ); + $this->drift( 'ap_two', 0 ); + + $this->assertSame( 'promoted', $this->check() ); + $this->assertSame( 'aeav_only', TMDO_Mode_Manager::get( 'user' ) ); + } + + public function test_drift_in_one_group_blocks_promotion(): void { + $this->drift( 'ap_one', 0 ); + $this->drift( 'ap_two', 1 ); + + $this->assertSame( 'drift_not_clean', $this->check() ); + $this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) ); + } + + public function test_a_group_never_checked_blocks_promotion(): void { + $this->drift( 'ap_one', 0 ); + + $this->assertSame( 'drift_not_clean', $this->check() ); + } + + public function test_a_check_older_than_two_days_blocks_promotion(): void { + $this->drift( 'ap_one', 0 ); + $this->drift( 'ap_two', 0, 3 * DAY_IN_SECONDS ); + + $this->assertSame( 'drift_not_clean', $this->check() ); + } +} diff --git a/tests/unit/CliBridgeModeTest.php b/tests/unit/CliBridgeModeTest.php new file mode 100644 index 0000000..4c7dc80 --- /dev/null +++ b/tests/unit/CliBridgeModeTest.php @@ -0,0 +1,57 @@ + ` (2.2 PR-069): the CLI way to change an + * entity's bridge mode. It must go through TMDO_Mode_Manager::set(), so unsafe + * jumps and promotions refused by the Promotion Guard never reach the option. + * (The Guard matrix itself is covered by PromotionGuardTest.) + */ +class CliBridgeModeTest extends TestCase { + + protected function setUp(): void { + ini_set( 'error_log', '/dev/null' ); + $GLOBALS['_wp_options'] = array(); + $GLOBALS['_wp_filter_callbacks'] = array(); + $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true ); + TMDO_Mode_Manager::reset_cache(); + } + + private function modes( array $modes ): void { + $GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes; + TMDO_Mode_Manager::reset_cache(); + } + + public function test_sets_the_mode_of_one_entity(): void { + $this->modes( array( 'user' => 'disabled', 'post' => 'dual_write' ) ); + + ( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'dual_write' ), array() ); + + $this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) ); + $this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'post' ), 'other entities untouched' ); + } + + public function test_an_unsafe_jump_is_refused(): void { + $this->modes( array( 'user' => 'disabled' ) ); + + ( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'aeav_only' ), array() ); + + $this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'user' ) ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_a_promotion_refused_by_the_guard_leaves_the_mode_unchanged(): void { + eval( 'namespace HivePress; class Core {}' ); + $this->modes( array( 'user' => 'shadow_read' ) ); + + ( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'aeav_only' ), array() ); + + $this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) ); + $this->assertSame( array( 'user' => 'shadow_read' ), $GLOBALS['_wp_options']['wpdo_bridge_modes'] ); + } +} diff --git a/tests/unit/CliFilterStatusTest.php b/tests/unit/CliFilterStatusTest.php new file mode 100644 index 0000000..f3a54b8 --- /dev/null +++ b/tests/unit/CliFilterStatusTest.php @@ -0,0 +1,41 @@ +setAccessible( true ); + $method->invoke( null, $class, $label ); + return (string) end( $GLOBALS['_wp_cli_output'] ); + } + + public function test_filters_off_by_default_show_disabled(): void { + $this->assertStringContainsString( 'disabled', $this->filter_line( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter' ) ); + $this->assertStringContainsString( 'disabled', $this->filter_line( 'TMDO_Term_Comment_Misc_Bucket', 'Misc bucket' ) ); + } + + public function test_an_enabled_filter_shows_enabled(): void { + $GLOBALS['_wp_options'][ TMDO_Term_Comment_Garbage_Filter::OPT_ENABLED ] = '1'; + + $this->assertStringContainsString( '✓ enabled', $this->filter_line( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter' ) ); + } + + public function test_a_filter_whose_add_on_is_not_active_says_so(): void { + $this->assertStringContainsString( 'not installed', $this->filter_line( 'TMDO_No_Such_Filter', 'WC term count filter' ) ); + } +} diff --git a/tests/unit/PostFields/PostFieldsRegistrationTest.php b/tests/unit/PostFields/PostFieldsRegistrationTest.php index 112d572..4e9ddb3 100644 --- a/tests/unit/PostFields/PostFieldsRegistrationTest.php +++ b/tests/unit/PostFields/PostFieldsRegistrationTest.php @@ -33,13 +33,12 @@ class PostFieldsRegistrationTest extends TestCase { $this->assertTrue( true ); } - public function test_all_four_groups_are_registered(): void { + public function test_the_core_groups_are_registered(): void { WPDO_Post_Fields::register_entity_fields(); $expected = array( 'wp_core', 'attachment', - 'wc_product', 'nav_menu_item', ); @@ -92,38 +91,14 @@ class PostFieldsRegistrationTest extends TestCase { $this->assertSame( 'json', $field['type'] ); } - // ── wc_product group ───────────────────────────────────────────────────── + // ── WooCommerce products stay in native meta (2.2 PR-077) ─────────────── - public function test_wc_product_group_has_19_keys(): void { + public function test_core_registers_no_woocommerce_product_group(): void { WPDO_Post_Fields::register_entity_fields(); - $keys = $this->get_field_keys( 'wc_product' ); - $this->assertCount( 19, $keys, 'wc_product group should register exactly 19 keys.' ); - } - public function test_wc_product_critical_keys_present(): void { - WPDO_Post_Fields::register_entity_fields(); - $keys = $this->get_field_keys( 'wc_product' ); - foreach ( array( '_price', '_regular_price', '_sale_price', '_stock', '_stock_status', '_sku' ) as $key ) { - $this->assertContains( $key, $keys, "wc_product missing critical key: {$key}" ); - } - } - - public function test_wc_product_price_is_searchable_decimal(): void { - WPDO_Post_Fields::register_entity_fields(); - $field = $this->find_field( 'wc_product', '_price' ); - $this->assertNotNull( $field ); - $this->assertSame( 'decimal', $field['type'] ); - $this->assertTrue( (bool) ( $field['searchable'] ?? false ) ); - } - - public function test_wc_product_stock_status_is_enum_searchable(): void { - WPDO_Post_Fields::register_entity_fields(); - $field = $this->find_field( 'wc_product', '_stock_status' ); - $this->assertNotNull( $field ); - $this->assertSame( 'enum', $field['type'] ); - $this->assertTrue( (bool) ( $field['searchable'] ?? false ) ); - $this->assertContains( 'instock', $field['options'] ); - $this->assertContains( 'outofstock', $field['options'] ); + $this->assertSame( array(), WPDO_Entity_Registry::get_group_fields( 'post', 'wc_product' ), 'WooCommerce reads and writes product meta directly (stock by SQL)' ); + $this->assertNull( WPDO_Entity_Registry::get_field( 'post', '_price', 'product' ) ); + $this->assertSame( array(), WPDO_Post_Fields::groups_for_post_type( 'product' ) ); } // ── HivePress groups moved to the HivePress AddOn ─────────────────────── @@ -139,7 +114,6 @@ class PostFieldsRegistrationTest extends TestCase { public function test_core_groups_map_to_their_post_types(): void { $this->assertSame( 'attachment', WPDO_Post_Fields::post_type_for_group( 'attachment' ) ); - $this->assertSame( 'product', WPDO_Post_Fields::post_type_for_group( 'wc_product' ) ); $this->assertSame( 'nav_menu_item', WPDO_Post_Fields::post_type_for_group( 'nav_menu_item' ) ); $this->assertNull( WPDO_Post_Fields::post_type_for_group( 'wp_core' ), 'wp_core spans every post type' ); } @@ -149,7 +123,6 @@ class PostFieldsRegistrationTest extends TestCase { $this->assertSame( 'x_type', WPDO_Post_Fields::post_type_for_group( 'x_core' ) ); $this->assertSame( array( 'x_core' ), WPDO_Post_Fields::groups_for_post_type( 'x_type' ) ); - $this->assertSame( array( 'wc_product' ), WPDO_Post_Fields::groups_for_post_type( 'product' ) ); $this->assertNull( WPDO_Post_Fields::post_type_for_group( 'hp_listing_core' ), 'unregistered HivePress group is unknown to core' ); }