Merge pull request 'hardening 2.2: 2meet-data-optimizer 1.2.0 (drift detection, scope, CLI and diagnostics)' (#6) from hardening/2.2 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 12s
Tests / Unit Tests (push) Successful in 40s
Tests / PHP Lint (push) Successful in 55s
Tests / Integration Tests (push) Successful in 1m42s
Tests / PHPCS (push) Successful in 33s
Tests / PHPStan (push) Successful in 46s
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 12s
Tests / Unit Tests (push) Successful in 40s
Tests / PHP Lint (push) Successful in 55s
Tests / Integration Tests (push) Successful in 1m42s
Tests / PHPCS (push) Successful in 33s
Tests / PHPStan (push) Successful in 46s
This commit was merged in pull request #6.
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: 2meet Data Optimizer
|
* Plugin Name: 2meet Data Optimizer
|
||||||
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
||||||
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
||||||
* Version: 1.1.1
|
* Version: 1.2.0
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Tested up to: 6.9.4
|
* Tested up to: 6.9.4
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
define( 'TMDO_VERSION', '1.1.1' );
|
define( 'TMDO_VERSION', '1.2.0' );
|
||||||
define( 'TMDO_DB_VERSION', '2.1.0' );
|
define( 'TMDO_DB_VERSION', '2.1.0' );
|
||||||
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
||||||
@@ -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-user-stress-tester.php';
|
||||||
require_once TMDO_PATH . 'includes/class-tmdo-post-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/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-auto-promoter.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
|
||||||
|
|||||||
@@ -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 <entity> [--group] [--sample=<n>] [--format]`**(`TMDO_Drift_Check`):
|
||||||
|
逐 key 比對扁平表與原生 meta 的值(第一筆原生 row → 安全 unserialize →
|
||||||
|
`Type_Caster::to_db`),回報 miss / diff / extra。序列化 vs JSON、`50` vs `50.000000`
|
||||||
|
不算漂移;超過 255 字元存 NULL 的 text 不算 miss。結果寫入 `wpdo_drift_status`。(PR-070)
|
||||||
|
- **`drift-check --fix`**:刪除已刪實體與非本 group post type 的扁平列,其餘漂移列經
|
||||||
|
`TMDO_Entity_Migration_Engine::migrate_entity()` 從原生重建,清除列快取後再檢查一次。
|
||||||
|
不寫原生;aeav_only(扁平才是真實資料)一律拒絕。(PR-071、PR-073b)
|
||||||
|
- **每日抽樣**:`wpdo_daily_health_check` 對 dual_write 以上、非 aeav_only 的 entity 每個
|
||||||
|
group 抽 500 筆,結果取代既有紀錄。(PR-073)
|
||||||
|
- **Hook Bus 關閉期間的寫入補回**:entity 從 disabled 進入 dual_write / shadow_read,或
|
||||||
|
Hook Bus flag 由關轉開時,60 秒後以單次 WP-Cron `tmdo_drift_resync` 執行全量
|
||||||
|
`drift-check --fix`(不做 `post_modified_gmt` 增量:WooCommerce 直接 SQL 改庫存不會更新
|
||||||
|
post_modified)。停用 / 解除安裝時清除排程。(PR-074)
|
||||||
|
- **`wp tmdo bridge-mode <entity> <mode>`**:經 `TMDO_Mode_Manager::set()`,受
|
||||||
|
Promotion Guard 約束;被拒時 exit 1、模式不變。(PR-069)
|
||||||
|
- **`wp tmdo member-backfill --all`**:回填所有已註冊的 user group。(PR-068)
|
||||||
|
- **`wp tmdo cleanup-hp-transients --options [--dry-run|--confirm] [--batch=<n>]`**:
|
||||||
|
刪除 HivePress AddOn 0.3.0 之前寫進 wp_options 的 `_transient_wpdo_hp_pm_*`(含 timeout),
|
||||||
|
分批刪除(預設 5000),AddOn 未啟用也可執行。dev36 有 2,314,750 列。(PR-080)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Post query router 避開漂移中的 group**:上次 drift-check 有 miss / diff / extra 的
|
||||||
|
group 不路由,整個查詢維持原生(全有或全無);從未檢查的 group 照常路由;aeav_only
|
||||||
|
不受影響。(PR-072、PR-073b)
|
||||||
|
- **純排序搜尋 flat-first**:沒有路由篩選的 HivePress `_sort` 搜尋,在該 post type 每篇都有
|
||||||
|
扁平列時改由扁平表驅動(`STRAIGHT_JOIN` + INNER JOIN);Router 在 `wp_insert_post`
|
||||||
|
替新文章建空列,覆蓋率以 anti-join 檢查並快取 15 分鐘(隨 post 模式變更失效)。
|
||||||
|
flat-first 且扁平表是唯一 JOIN 時拿掉 WordPress 為 tax query 加的 `GROUP BY posts.ID`。
|
||||||
|
dev36 `rating__desc`:原生約 3.3 s、1.1.0 約 1.5–1.9 s、現在約 0.51–0.85 s。(PR-079)
|
||||||
|
- **Auto-promoter** 的第三個條件改為 `TMDO_Drift_Check::clean_since()`:2 天內每個 group 都
|
||||||
|
檢查過(抽樣或全量)且沒有漂移;沒有紀錄就不升級。(PR-075)
|
||||||
|
- **shadow_read diff 節流**:每個 entity type + key 每小時最多記 20 列(疊加既有的
|
||||||
|
每物件 5 分鐘視窗)。(PR-075)
|
||||||
|
- **回填 group 改讀 registry**:admin「全部回填」、`post-migrate-group`、`member-backfill`、
|
||||||
|
`member-audit` 取所有已註冊 group(排除已還原的孤兒 group),不再是寫死清單;含 json
|
||||||
|
欄位的 group(attachment、nav_menu_item)逐列經 migration engine。(PR-067、PR-068)
|
||||||
|
- **不再註冊 `post/wc_product`**:WooCommerce 直接讀 product meta、以 SQL 寫庫存,扁平副本
|
||||||
|
必然漂移(ADR-004 決策 2)。既有的 `wp_wpdo_post_wc_product` 表保留,以
|
||||||
|
`wp tmdo bridge-restore-native post --group=wc_product --retire` 封存;解除安裝仍會刪除。(PR-077)
|
||||||
|
- `member-sso-sync`、`member-force-logout` 標示為 deprecated(`sso` group 已不註冊)。(PR-069)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Entity 回填替沒有原生 row 的 key 寫入 DDL 預設值(`session_count` / `account_flags` = 0),
|
||||||
|
每個 user 都被捏造出 0;現在寫 NULL,required NOT NULL 欄位保留預設值。(PR-066)
|
||||||
|
- `member-cutover` 在轉換被拒時 fatal(把 `WP_Error` 當陣列)。(PR-068)
|
||||||
|
- `doctor` 依 registry 檢查 entity 表(不再因從未註冊的 `sso` 表而每站失敗),並顯示
|
||||||
|
Promotion Guard 違規與 drift 狀態;`term-comment-diagnose` 依各 filter 的 `is_enabled()`
|
||||||
|
顯示狀態;`bridge-status` 只計有值的扁平列、eav_ids 限定 group 的 post type
|
||||||
|
(dev36 wp_core 15306% → 100%)。(PR-076)
|
||||||
|
- 2.1 稽核:公開 REST route 註明公開理由;admin 23 處三元 echo 依情境 `esc_attr` /
|
||||||
|
`esc_html`;migration engine 與 health cron 的例外改記 `TMDO_Logger`。(PR-078)
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- ADR-004「設計(Release 2.2)」、`CONTEXT.md` Entity Bridge 的漂移段落。(PR-086)
|
||||||
|
|
||||||
|
### 升級後
|
||||||
|
|
||||||
|
- HivePress 站:部署 AddOn 0.3.0 後執行 `wp tmdo cleanup-hp-transients --options --confirm`。
|
||||||
|
- 曾註冊 wc_product 的站:`wp tmdo bridge-restore-native post --group=wc_product --retire`。
|
||||||
|
- 已進入 dual_write 以上的站:`wp tmdo drift-check <entity>` 建立第一筆紀錄(未檢查的
|
||||||
|
group 照常路由)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.1.1] — 2026-09-26 — Security:公開 REST 不再回傳私有欄位
|
## [1.1.1] — 2026-09-26 — Security:公開 REST 不再回傳私有欄位
|
||||||
|
|
||||||
**性質**:安全修正(Critical)。1.0.x 起即存在;1.1.0 zip 已撤回(`dist/withdrawn/`)。
|
**性質**:安全修正(Critical)。1.0.x 起即存在;1.1.0 zip 已撤回(`dist/withdrawn/`)。
|
||||||
|
|||||||
@@ -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
|
shape, a named clause, `meta_key` + `meta_value_num`) is routed only as the sole
|
||||||
`orderby` on a column that orders like native.
|
`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)
|
## Migration Phase (Strategy pattern)
|
||||||
|
|||||||
+14
-10
@@ -556,11 +556,11 @@ class TMDO_Admin {
|
|||||||
exit;
|
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' ) ) {
|
if ( isset( $_POST['wpdo_post_backfill_all'] ) && check_admin_referer( 'wpdo_post_backfill_all' ) && class_exists( 'TMDO_Post_Migration' ) ) {
|
||||||
$total_migrated = 0;
|
$total_migrated = 0;
|
||||||
$errors = array();
|
$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 {
|
try {
|
||||||
$result = TMDO_Post_Migration::backfill_group( $group );
|
$result = TMDO_Post_Migration::backfill_group( $group );
|
||||||
$total_migrated += (int) $result['migrated'];
|
$total_migrated += (int) $result['migrated'];
|
||||||
@@ -846,8 +846,8 @@ class TMDO_Admin {
|
|||||||
<span class="wpdo-tab-group-label" aria-hidden="true"><?php echo esc_html( $group['label'] ); ?></span>
|
<span class="wpdo-tab-group-label" aria-hidden="true"><?php echo esc_html( $group['label'] ); ?></span>
|
||||||
<?php foreach ( $group_tabs as $slug ) : ?>
|
<?php foreach ( $group_tabs as $slug ) : ?>
|
||||||
<a href="<?php echo esc_url( add_query_arg( 'tab', $slug, admin_url( 'tools.php?page=' . self::MENU_SLUG ) ) ); ?>"
|
<a href="<?php echo esc_url( add_query_arg( 'tab', $slug, admin_url( 'tools.php?page=' . self::MENU_SLUG ) ) ); ?>"
|
||||||
class="nav-tab <?php echo $tab === $slug ? 'nav-tab-active' : ''; ?>"
|
class="nav-tab <?php echo esc_attr( $tab === $slug ? 'nav-tab-active' : '' ); ?>"
|
||||||
<?php echo $tab === $slug ? 'aria-current="page"' : ''; ?>>
|
aria-current="<?php echo esc_attr( $tab === $slug ? 'page' : 'false' ); ?>">
|
||||||
<?php echo esc_html( $tabs[ $slug ] ); ?>
|
<?php echo esc_html( $tabs[ $slug ] ); ?>
|
||||||
<?php if ( isset( $needs_dot[ $slug ] ) ) : ?>
|
<?php if ( isset( $needs_dot[ $slug ] ) ) : ?>
|
||||||
<span class="wpdo-tab-dot" title="
|
<span class="wpdo-tab-dot" title="
|
||||||
@@ -1099,7 +1099,7 @@ class TMDO_Admin {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e( '資料庫引擎', '2meet-data-optimizer' ); ?></th>
|
<th><?php esc_html_e( '資料庫引擎', '2meet-data-optimizer' ); ?></th>
|
||||||
<td><code><?php echo TMDO_IS_MYSQL ? 'MySQL' : 'SQLite'; ?></code></td>
|
<td><code><?php echo esc_html( TMDO_IS_MYSQL ? 'MySQL' : 'SQLite' ); ?></code></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e( '外掛版本', '2meet-data-optimizer' ); ?></th>
|
<th><?php esc_html_e( '外掛版本', '2meet-data-optimizer' ); ?></th>
|
||||||
@@ -1116,7 +1116,11 @@ class TMDO_Admin {
|
|||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e( 'Object Cache', '2meet-data-optimizer' ); ?></th>
|
<th><?php esc_html_e( 'Object Cache', '2meet-data-optimizer' ); ?></th>
|
||||||
<td>
|
<td>
|
||||||
<?php echo wp_using_ext_object_cache() ? '<span class="wpdo-badge wpdo-badge-ok">External</span>' : '<span class="wpdo-badge">Built-in</span>'; ?>
|
<?php if ( wp_using_ext_object_cache() ) : ?>
|
||||||
|
<span class="wpdo-badge wpdo-badge-ok">External</span>
|
||||||
|
<?php else : ?>
|
||||||
|
<span class="wpdo-badge">Built-in</span>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if ( $cache['flush_support'] ) : ?>
|
<?php if ( $cache['flush_support'] ) : ?>
|
||||||
<span class="wpdo-badge wpdo-badge-ok">Flush Support</span>
|
<span class="wpdo-badge wpdo-badge-ok">Flush Support</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -1726,13 +1730,13 @@ class TMDO_Admin {
|
|||||||
↑ <?php esc_html_e( '升級模式', '2meet-data-optimizer' ); ?>
|
↑ <?php esc_html_e( '升級模式', '2meet-data-optimizer' ); ?>
|
||||||
</button>
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="button wpdo-btn-demote <?php echo $prev_mode ? '' : 'wpdo-eb-btn-dim'; ?>"
|
class="button wpdo-btn-demote <?php echo esc_attr( $prev_mode ? '' : 'wpdo-eb-btn-dim' ); ?>"
|
||||||
data-wpdo-action="demote"
|
data-wpdo-action="demote"
|
||||||
data-entity-type="<?php echo esc_attr( $etype ); ?>"
|
data-entity-type="<?php echo esc_attr( $etype ); ?>"
|
||||||
data-prev-mode="<?php echo esc_attr( $prev_mode ?? '' ); ?>"
|
data-prev-mode="<?php echo esc_attr( $prev_mode ?? '' ); ?>"
|
||||||
<?php disabled( ! $prev_mode ); ?>
|
<?php disabled( ! $prev_mode ); ?>
|
||||||
title="<?php echo esc_attr( $prev_mode ? '降級到 ' . $prev_mode : '已在最低模式' ); ?>"
|
title="<?php echo esc_attr( $prev_mode ? '降級到 ' . $prev_mode : '已在最低模式' ); ?>"
|
||||||
class="<?php echo $prev_mode ? '' : 'wpdo-eb-btn-dim'; ?>">
|
class="<?php echo esc_attr( $prev_mode ? '' : 'wpdo-eb-btn-dim' ); ?>">
|
||||||
↓ <?php esc_html_e( '降級模式', '2meet-data-optimizer' ); ?>
|
↓ <?php esc_html_e( '降級模式', '2meet-data-optimizer' ); ?>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2037,7 +2041,7 @@ class TMDO_Admin {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress section (live) -->
|
<!-- Progress section (live) -->
|
||||||
<div id="wpdo-st-progress-card" class="wpdo-eb-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
|
<div id="wpdo-st-progress-card" class="wpdo-eb-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div class="wpdo-bar-section">
|
<div class="wpdo-bar-section">
|
||||||
<div class="wpdo-bar-header">
|
<div class="wpdo-bar-header">
|
||||||
@@ -2071,7 +2075,7 @@ class TMDO_Admin {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Benchmark report -->
|
<!-- Benchmark report -->
|
||||||
<div id="wpdo-st-bench-card" class="wpdo-eb-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
|
<div id="wpdo-st-bench-card" class="wpdo-eb-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div id="wpdo-st-bench-content">
|
<div id="wpdo-st-bench-content">
|
||||||
<?php if ( ! empty( $state['benchmark'] ) ) : ?>
|
<?php if ( ! empty( $state['benchmark'] ) ) : ?>
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ class TMDO_Dashboard_Widget {
|
|||||||
'%2$s'
|
'%2$s'
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
<p class="wpdo-widget-notice <?php echo $total_eav > 0 ? 'wpdo-widget-notice-warn' : 'wpdo-widget-notice-ok'; ?>">
|
<p class="wpdo-widget-notice <?php echo esc_attr( $total_eav > 0 ? 'wpdo-widget-notice-warn' : 'wpdo-widget-notice-ok' ); ?>">
|
||||||
📦
|
📦
|
||||||
<?php
|
<?php
|
||||||
if ( $total_eav > 0 ) {
|
if ( $total_eav > 0 ) {
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress card (live) -->
|
<!-- Progress card (live) -->
|
||||||
<div id="wpdo-cst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
|
<div id="wpdo-cst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div class="wpdo-bar-section">
|
<div class="wpdo-bar-section">
|
||||||
<div class="wpdo-bar-header">
|
<div class="wpdo-bar-header">
|
||||||
@@ -249,7 +249,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Benchmark report -->
|
<!-- Benchmark report -->
|
||||||
<div id="wpdo-cst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
|
<div id="wpdo-cst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div id="wpdo-cst-bench-content"></div>
|
<div id="wpdo-cst-bench-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ $failed = 'failed' === $state;
|
|||||||
|
|
||||||
<!-- Run-migration panel (idle state) ──────────────────────────────── -->
|
<!-- Run-migration panel (idle state) ──────────────────────────────── -->
|
||||||
<section class="wpdo-mw-panel wpdo-mw-run" id="wpdo-mw-run-panel"
|
<section class="wpdo-mw-panel wpdo-mw-run" id="wpdo-mw-run-panel"
|
||||||
<?php echo $active || $completed ? 'hidden' : ''; ?>>
|
<?php echo esc_attr( $active || $completed ? 'hidden' : '' ); ?>>
|
||||||
|
|
||||||
<?php if ( 0 === $eav_residue && 'aeav_only' === $current_mode ) : ?>
|
<?php if ( 0 === $eav_residue && 'aeav_only' === $current_mode ) : ?>
|
||||||
<div class="wpdo-mw-nothing-todo">
|
<div class="wpdo-mw-nothing-todo">
|
||||||
@@ -170,7 +170,7 @@ $failed = 'failed' === $state;
|
|||||||
|
|
||||||
<!-- Live progress panel (running state) ───────────────────────────── -->
|
<!-- Live progress panel (running state) ───────────────────────────── -->
|
||||||
<section class="wpdo-mw-panel wpdo-mw-progress" id="wpdo-mw-progress-panel"
|
<section class="wpdo-mw-panel wpdo-mw-progress" id="wpdo-mw-progress-panel"
|
||||||
<?php echo $active || $failed ? '' : 'hidden'; ?>>
|
<?php echo esc_attr( $active || $failed ? '' : 'hidden' ); ?>>
|
||||||
|
|
||||||
<h3 id="wpdo-mw-progress-title"><?php esc_html_e( '執行中…', '2meet-data-optimizer' ); ?></h3>
|
<h3 id="wpdo-mw-progress-title"><?php esc_html_e( '執行中…', '2meet-data-optimizer' ); ?></h3>
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ $failed = 'failed' === $state;
|
|||||||
|
|
||||||
<!-- Completed state ───────────────────────────────────────────────── -->
|
<!-- Completed state ───────────────────────────────────────────────── -->
|
||||||
<section class="wpdo-mw-panel wpdo-mw-done" id="wpdo-mw-done-panel"
|
<section class="wpdo-mw-panel wpdo-mw-done" id="wpdo-mw-done-panel"
|
||||||
<?php echo $completed ? '' : 'hidden'; ?>>
|
<?php echo esc_attr( $completed ? '' : 'hidden' ); ?>>
|
||||||
<h3>✅ <?php esc_html_e( '遷移完成', '2meet-data-optimizer' ); ?></h3>
|
<h3>✅ <?php esc_html_e( '遷移完成', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div class="wpdo-mw-done-summary" id="wpdo-mw-done-summary"></div>
|
<div class="wpdo-mw-done-summary" id="wpdo-mw-done-summary"></div>
|
||||||
<button type="button" class="button" id="wpdo-mw-reset"><?php esc_html_e( '回到診斷頁', '2meet-data-optimizer' ); ?></button>
|
<button type="button" class="button" id="wpdo-mw-reset"><?php esc_html_e( '回到診斷頁', '2meet-data-optimizer' ); ?></button>
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ $wpdo_format_msg = static function ( string $code ): string {
|
|||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="wpdo_postmeta_cleanup" value="1">
|
<input type="hidden" name="wpdo_postmeta_cleanup" value="1">
|
||||||
<?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?>
|
<?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?>
|
||||||
<button type="submit" class="button button-primary <?php echo (int) $garbage['total'] > 0 ? '' : 'disabled'; ?>"
|
<button type="submit" class="button button-primary <?php echo esc_attr( (int) $garbage['total'] > 0 ? '' : 'disabled' ); ?>"
|
||||||
onclick="return confirm(<?php echo wp_json_encode( __( '確定要刪除 wp_postmeta 中的垃圾資料?此動作不可逆。', '2meet-data-optimizer' ) ); ?>);">
|
onclick="return confirm(<?php echo wp_json_encode( __( '確定要刪除 wp_postmeta 中的垃圾資料?此動作不可逆。', '2meet-data-optimizer' ) ); ?>);">
|
||||||
<?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?>
|
<?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -161,22 +161,22 @@ $post_type_options = array(
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr class="<?php echo 'disabled' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>">
|
<tr class="<?php echo esc_attr( 'disabled' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
|
||||||
<td><code>disabled</code></td>
|
<td><code>disabled</code></td>
|
||||||
<td>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td>
|
<td>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td>
|
||||||
<td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td>
|
<td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="<?php echo 'dual_write' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>">
|
<tr class="<?php echo esc_attr( 'dual_write' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
|
||||||
<td><code>dual_write</code></td>
|
<td><code>dual_write</code></td>
|
||||||
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
||||||
<td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td>
|
<td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="<?php echo 'shadow_read' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>">
|
<tr class="<?php echo esc_attr( 'shadow_read' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
|
||||||
<td><code>shadow_read</code></td>
|
<td><code>shadow_read</code></td>
|
||||||
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
<td>wp_postmeta 5 / flat 0 → 1:5</td>
|
||||||
<td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td>
|
<td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="<?php echo 'aeav_only' === $current_post_mode ? 'wpdo-tr-success' : ''; ?>">
|
<tr class="<?php echo esc_attr( 'aeav_only' === $current_post_mode ? 'wpdo-tr-success' : '' ); ?>">
|
||||||
<td><code>aeav_only</code></td>
|
<td><code>aeav_only</code></td>
|
||||||
<td>wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️</td>
|
<td>wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️</td>
|
||||||
<td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td>
|
<td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td>
|
||||||
@@ -291,7 +291,7 @@ $post_type_options = array(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress section (live) -->
|
<!-- Progress section (live) -->
|
||||||
<div id="wpdo-pst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
|
<div id="wpdo-pst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div class="wpdo-bar-section">
|
<div class="wpdo-bar-section">
|
||||||
<div class="wpdo-bar-header">
|
<div class="wpdo-bar-header">
|
||||||
@@ -329,7 +329,7 @@ $post_type_options = array(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Benchmark report -->
|
<!-- Benchmark report -->
|
||||||
<div id="wpdo-pst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
|
<div id="wpdo-pst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div id="wpdo-pst-bench-content"></div>
|
<div id="wpdo-pst-bench-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress card (live) -->
|
<!-- Progress card (live) -->
|
||||||
<div id="wpdo-tst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>">
|
<div id="wpdo-tst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div class="wpdo-bar-section">
|
<div class="wpdo-bar-section">
|
||||||
<div class="wpdo-bar-header">
|
<div class="wpdo-bar-header">
|
||||||
@@ -245,7 +245,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Benchmark report -->
|
<!-- Benchmark report -->
|
||||||
<div id="wpdo-tst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>">
|
<div id="wpdo-tst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
|
||||||
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
|
||||||
<div id="wpdo-tst-bench-content"></div>
|
<div id="wpdo-tst-bench-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
* wp wpdo member-shadow-report — Recent entity_type=user diff records.
|
* wp wpdo member-shadow-report — Recent entity_type=user diff records.
|
||||||
* wp wpdo member-cutover — Switch user mode to aeav_only.
|
* 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-points-check — Show balance + recent ledger for a user.
|
||||||
* wp wpdo member-sso-sync — Sync Hub membership claims to flat table.
|
* wp wpdo member-sso-sync — (deprecated) Sync Hub membership claims to flat table.
|
||||||
* wp wpdo member-force-logout — Set token_expires_at to past → re-auth.
|
* wp wpdo member-force-logout — (deprecated) Set token_expires_at to past → re-auth.
|
||||||
*
|
*
|
||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
* @since 2.5.5
|
* @since 2.5.5
|
||||||
@@ -35,24 +35,6 @@ if ( ! class_exists( 'WP_CLI' ) ) {
|
|||||||
*/
|
*/
|
||||||
class TMDO_CLI_Member {
|
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 ─────────────────────────────────────────────────────────
|
// ── member-audit ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,7 +55,7 @@ class TMDO_CLI_Member {
|
|||||||
WP_CLI::log( '' );
|
WP_CLI::log( '' );
|
||||||
|
|
||||||
$items = array();
|
$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;
|
$table = $wpdb->prefix . 'wpdo_user_' . $group;
|
||||||
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
||||||
@@ -111,7 +93,7 @@ class TMDO_CLI_Member {
|
|||||||
// ── member-backfill ───────────────────────────────────────────────────────
|
// ── 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,
|
* Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based,
|
||||||
* 500 rows/batch, checkpoint stored in wpdo_migration_status.
|
* 500 rows/batch, checkpoint stored in wpdo_migration_status.
|
||||||
@@ -119,7 +101,10 @@ class TMDO_CLI_Member {
|
|||||||
* ## OPTIONS
|
* ## OPTIONS
|
||||||
*
|
*
|
||||||
* [--group=<group>]
|
* [--group=<group>]
|
||||||
* : Which group to backfill: membership, activity, profile, sso. Default: membership.
|
* : Registered user group to backfill (e.g. membership, activity, core_profile). Default: membership.
|
||||||
|
*
|
||||||
|
* [--all]
|
||||||
|
* : Backfill every registered user group (restored orphan groups excluded).
|
||||||
*
|
*
|
||||||
* [--batch-size=<n>]
|
* [--batch-size=<n>]
|
||||||
* : Rows per batch (default 500, max 2000).
|
* : 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 --dry-run
|
||||||
* wp wpdo member-backfill --group=membership
|
* 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 $args Positional arguments.
|
||||||
* @param array $assoc_args Named arguments.
|
* @param array $assoc_args Named arguments.
|
||||||
*/
|
*/
|
||||||
public function member_backfill( $args, $assoc_args ): void {
|
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 ) ) );
|
$batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) );
|
||||||
$dry_run = isset( $assoc_args['dry-run'] );
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
$reset = isset( $assoc_args['reset'] );
|
$reset = isset( $assoc_args['reset'] );
|
||||||
|
$valid = TMDO_Entity_Migration_Engine::backfillable_groups( 'user' );
|
||||||
|
|
||||||
if ( ! in_array( $group, self::VALID_GROUPS, true ) ) {
|
if ( isset( $assoc_args['all'] ) ) {
|
||||||
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) );
|
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 ) {
|
if ( $reset ) {
|
||||||
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
|
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
|
||||||
WP_CLI::log( "Checkpoint cleared for 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' );
|
$result = TMDO_Mode_Manager::set( 'user', 'aeav_only' );
|
||||||
|
|
||||||
if ( isset( $result['error'] ) ) {
|
if ( is_wp_error( $result ) ) {
|
||||||
WP_CLI::error( 'Cutover failed: ' . $result['error'] );
|
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.' );
|
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.
|
* 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
|
* Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token
|
||||||
* from usermeta and writes them into the sso group flat table.
|
* 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
|
* 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.
|
* @param array $assoc_args Named arguments.
|
||||||
*/
|
*/
|
||||||
public function member_sso_sync( $args, $assoc_args ): void {
|
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;
|
global $wpdb;
|
||||||
|
|
||||||
$user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0;
|
$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.
|
* 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
|
* 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
|
* redirects the user to re-authenticate against the Hub. If the Hub has
|
||||||
* the account blocked, the user is logged out from all Spokes.
|
* 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.
|
* @param array $assoc_args Named arguments.
|
||||||
*/
|
*/
|
||||||
public function member_force_logout( $args, $assoc_args ): void {
|
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'] ) ) {
|
if ( ! isset( $assoc_args['confirm'] ) ) {
|
||||||
WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' );
|
WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' );
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-15
@@ -198,29 +198,20 @@ class TMDO_CLI_Post {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Backfill one post entity group from wp_postmeta into its flat table
|
* 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
|
* Idempotent — safe to re-run.
|
||||||
* row-by-row backfill phase in v2.9.4+).
|
|
||||||
*
|
*
|
||||||
* ## OPTIONS
|
* ## OPTIONS
|
||||||
*
|
*
|
||||||
* --group=<group>
|
* --group=<group>
|
||||||
* : Entity group name. Required.
|
* : Registered post entity group name (e.g. wp_core, attachment,
|
||||||
* ---
|
* hp_listing_core). Required.
|
||||||
* options:
|
|
||||||
* - wp_core
|
|
||||||
* - attachment
|
|
||||||
* - wc_product
|
|
||||||
* - hp_listing_core
|
|
||||||
* - hp_request_core
|
|
||||||
* - hp_vendor_core
|
|
||||||
* - nav_menu_item
|
|
||||||
* ---
|
|
||||||
*
|
*
|
||||||
* ## EXAMPLES
|
* ## 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
|
* wp wpdo post-migrate-group --group=hp_listing_core
|
||||||
*
|
*
|
||||||
* @param array $args Positional arguments (unused).
|
* @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
|
* Safe to run with the filter enabled; the filter prevents new postmeta
|
||||||
* writes from re-bloating the table.
|
* 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
|
* ## OPTIONS
|
||||||
*
|
*
|
||||||
* [--dry-run]
|
* [--dry-run]
|
||||||
@@ -447,10 +442,17 @@ class TMDO_CLI_Post {
|
|||||||
* [--confirm]
|
* [--confirm]
|
||||||
* : Required to actually DELETE rows.
|
* : Required to actually DELETE rows.
|
||||||
*
|
*
|
||||||
|
* [--options]
|
||||||
|
* : Clean the AddOn's rows in wp_options instead of wp_postmeta.
|
||||||
|
*
|
||||||
|
* [--batch=<n>]
|
||||||
|
* : Rows per DELETE with --options. Default 5000.
|
||||||
|
*
|
||||||
* ## EXAMPLES
|
* ## EXAMPLES
|
||||||
*
|
*
|
||||||
* wp wpdo cleanup-hp-transients --dry-run
|
* wp wpdo cleanup-hp-transients --dry-run
|
||||||
* wp wpdo cleanup-hp-transients --confirm
|
* wp wpdo cleanup-hp-transients --confirm
|
||||||
|
* wp wpdo cleanup-hp-transients --options --dry-run
|
||||||
*
|
*
|
||||||
* @param array $args Positional arguments (unused).
|
* @param array $args Positional arguments (unused).
|
||||||
* @param array $assoc_args Named arguments.
|
* @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' ) ) {
|
if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
|
||||||
WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' );
|
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) ──────────────────────────────────────────
|
// ── post-cutover-legacy (v2.9.5) ──────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -442,9 +442,9 @@ class TMDO_CLI_Term_Comment {
|
|||||||
|
|
||||||
WP_CLI::log( '' );
|
WP_CLI::log( '' );
|
||||||
WP_CLI::log( '── Filter chain status ──' );
|
WP_CLI::log( '── Filter chain status ──' );
|
||||||
self::print_filter_status( 'wpdo_term_comment_garbage_filter_enabled', 'Garbage filter (Phase 1)' );
|
self::print_filter_status( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter (Phase 1)' );
|
||||||
self::print_filter_status( 'wpdo_wc_term_count_filter_enabled', 'WC term count filter (Phase 3)' );
|
self::print_filter_status( 'TMDO_WC_Term_Count_Filter', '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_Misc_Bucket', 'Misc bucket (Phase 4)' );
|
||||||
|
|
||||||
WP_CLI::log( '' );
|
WP_CLI::log( '' );
|
||||||
WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) );
|
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 $filter_class Filter class with a static is_enabled().
|
||||||
* @param string $label Human-readable name.
|
* @param string $label Human-readable name.
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function print_filter_status( string $option_key, string $label ): void {
|
private static function print_filter_status( string $filter_class, string $label ): void {
|
||||||
$enabled = (bool) get_option( $option_key, '1' );
|
if ( ! class_exists( $filter_class ) ) {
|
||||||
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $enabled ? '✓ enabled' : '✗ disabled' ) );
|
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) ──────────────────────────
|
// ── term-comment-shadow-report (v2.12.5 Phase 5) ──────────────────────────
|
||||||
|
|||||||
+142
-6
@@ -91,22 +91,29 @@ class TMDO_CLI_V2 {
|
|||||||
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table )
|
$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
|
// 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;
|
: 0;
|
||||||
|
|
||||||
// EAV source: count distinct entity IDs that have at least one managed key.
|
// 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_table = $eav_tables[ $entity_type ] ?? '';
|
$eav_count = 0;
|
||||||
$eav_count = 0;
|
|
||||||
|
|
||||||
if ( '' !== $eav_table && ! empty( $managed_keys ) ) {
|
if ( '' !== $eav_table && ! empty( $managed_keys ) ) {
|
||||||
$id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id";
|
$id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id";
|
||||||
$placeholders = implode( ', ', array_fill( 0, count( $managed_keys ), '%s' ) );
|
$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.
|
// $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
|
// 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
|
$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}" );
|
WP_CLI::success( "Hook Bus: {$state}" );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an entity's bridge mode. Goes through TMDO_Mode_Manager::set(): unsafe
|
||||||
|
* jumps and promotions refused by the Promotion Guard (aeav_only on
|
||||||
|
* HivePress / WooCommerce sites) fail and leave the mode unchanged.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* <entity>
|
||||||
|
* : post | user | term | comment
|
||||||
|
*
|
||||||
|
* <mode>
|
||||||
|
* : disabled | dual_write | shadow_read | aeav_only
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo bridge-mode user dual_write
|
||||||
|
* wp tmdo bridge-mode post disabled
|
||||||
|
*
|
||||||
|
* @param array<int, string> $args Entity type and mode.
|
||||||
|
* @param array<string, string> $assoc_args Unused.
|
||||||
|
*/
|
||||||
|
public function bridge_mode( $args, $assoc_args ): void {
|
||||||
|
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
|
||||||
|
$entity = (string) ( $args[0] ?? '' );
|
||||||
|
$mode = (string) ( $args[1] ?? '' );
|
||||||
|
$from = TMDO_Mode_Manager::get( $entity );
|
||||||
|
|
||||||
|
$result = TMDO_Mode_Manager::set( $entity, $mode );
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
WP_CLI::error( $result->get_error_message() );
|
||||||
|
}
|
||||||
|
WP_CLI::success( "{$entity}: {$from} → {$mode}" );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare an entity's flat tables with native meta, optionally repairing them.
|
||||||
|
*
|
||||||
|
* Reports per key how many values are missing from flat (miss), differ
|
||||||
|
* (diff) or exist only in flat (extra), and records the outcome per group
|
||||||
|
* in the wpdo_drift_status option. Read-only unless --fix is given.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* <entity>
|
||||||
|
* : post | user | term | comment
|
||||||
|
*
|
||||||
|
* [--group=<group>]
|
||||||
|
* : Only this group. Default: every backfillable group.
|
||||||
|
*
|
||||||
|
* [--sample=<n>]
|
||||||
|
* : Check n random entities per group instead of all of them.
|
||||||
|
*
|
||||||
|
* [--fix]
|
||||||
|
* : Rebuild drifted flat rows from native meta (native is never written),
|
||||||
|
* delete rows of deleted entities / other post types, then check again.
|
||||||
|
* Not combinable with --sample.
|
||||||
|
*
|
||||||
|
* [--format=<format>]
|
||||||
|
* : table or json. Default: table.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo drift-check post
|
||||||
|
* wp tmdo drift-check user --group=activity
|
||||||
|
* wp tmdo drift-check post --sample=500
|
||||||
|
* wp tmdo drift-check post --fix
|
||||||
|
*
|
||||||
|
* @param array<int, string> $args Entity type.
|
||||||
|
* @param array<string, string> $assoc_args Options.
|
||||||
|
*/
|
||||||
|
public function drift_check( $args, $assoc_args ): void {
|
||||||
|
$entity = (string) ( $args[0] ?? '' );
|
||||||
|
$group = (string) ( $assoc_args['group'] ?? '' );
|
||||||
|
$sample = max( 0, (int) ( $assoc_args['sample'] ?? 0 ) );
|
||||||
|
$fix = isset( $assoc_args['fix'] );
|
||||||
|
if ( $fix && $sample ) {
|
||||||
|
WP_CLI::error( '--fix repairs every entity; it cannot be combined with --sample.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ( $fix ) {
|
||||||
|
$result = TMDO_Drift_Check::fix( $entity, $group );
|
||||||
|
$report = $result['after'];
|
||||||
|
WP_CLI::log( sprintf( 'Fixed: %d row(s) rebuilt from native, %d cleared, %d stray row(s) deleted.', $result['fixed']['rebuilt'], $result['fixed']['cleared'], $result['fixed']['deleted_rows'] ) );
|
||||||
|
} else {
|
||||||
|
$report = TMDO_Drift_Check::run( $entity, $group, $sample );
|
||||||
|
}
|
||||||
|
} catch ( InvalidArgumentException | RuntimeException $e ) {
|
||||||
|
WP_CLI::error( $e->getMessage() );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 'json' === ( $assoc_args['format'] ?? 'table' ) ) {
|
||||||
|
WP_CLI::line( (string) wp_json_encode( $report ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = array();
|
||||||
|
foreach ( $report['groups'] as $group => $result ) {
|
||||||
|
foreach ( $result['keys'] as $key => $counts ) {
|
||||||
|
if ( $counts['miss'] + $counts['diff'] + $counts['extra'] > 0 ) {
|
||||||
|
$rows[] = array_merge(
|
||||||
|
array(
|
||||||
|
'group' => $group,
|
||||||
|
'key' => $key,
|
||||||
|
),
|
||||||
|
$counts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( $rows ) {
|
||||||
|
\WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'key', 'miss', 'diff', 'extra' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = $report['total'];
|
||||||
|
$summary = sprintf( '%s: %d group(s)%s — miss=%d diff=%d extra=%d', $entity, count( $report['groups'] ), $sample ? " (sample {$sample})" : '', $total['miss'], $total['diff'], $total['extra'] );
|
||||||
|
if ( $total['miss'] + $total['diff'] + $total['extra'] > 0 ) {
|
||||||
|
WP_CLI::warning( $summary );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WP_CLI::success( $summary );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write an entity's flat-table values back to native meta — the way out of
|
* Write an entity's flat-table values back to native meta — the way out of
|
||||||
* aeav_only. Resumable; re-run after an interruption.
|
* 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( '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( '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( '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( '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( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
|
||||||
WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
|
WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
|
||||||
|
|||||||
+83
-23
@@ -94,6 +94,84 @@ class TMDO_CLI {
|
|||||||
WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) );
|
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.
|
* 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)" );
|
WP_CLI::log( $exists ? " [OK] {$t}" : " [NOT CREATED] {$t} (will be created on first use)" );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check user entity flat tables (v2.5.5+).
|
// Entity flat tables, from the registry (was a hard-coded user list that
|
||||||
$user_entity_tables = array(
|
// named the never-registered sso group).
|
||||||
$wpdb->prefix . 'wpdo_user_membership',
|
if ( ! self::check_entity_tables() ) {
|
||||||
$wpdb->prefix . 'wpdo_user_activity',
|
$all_ok = false;
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
self::report_guard_and_drift();
|
||||||
|
|
||||||
// Check partner plugin custom tables via Custom Table Registry (v2.6.0+).
|
// Check partner plugin custom tables via Custom Table Registry (v2.6.0+).
|
||||||
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
|
||||||
|
|||||||
@@ -77,6 +77,26 @@
|
|||||||
|
|
||||||
HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `optimize_query` / `optimize_search`。
|
HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `optimize_query` / `optimize_search`。
|
||||||
|
|
||||||
|
## 設計(Release 2.2)
|
||||||
|
|
||||||
|
### 範圍收斂
|
||||||
|
|
||||||
|
- core 不再註冊 `post/wc_product`;WooCommerce AddOn 0.2.0 不再註冊 product / order / subscription 的 zone 欄位與 user `hot` 群組。WooCommerce 的 meta 只存在原生。既有表以 `bridge-restore-native post --group=wc_product --retire` 封存。
|
||||||
|
|
||||||
|
### 漂移偵測與修復(`TMDO_Drift_Check`)
|
||||||
|
|
||||||
|
- `wp tmdo drift-check <entity>` 以值比對扁平與原生(第一筆原生 row → 安全 unserialize → `Type_Caster::to_db`),逐 key 回報 miss / diff / extra,結果寫入 `wpdo_drift_status`。
|
||||||
|
- `--fix` 從原生重建漂移列,刪除已刪實體與非本 group post type 的列,不寫原生。
|
||||||
|
- **aeav_only 不適用**:那時扁平才是真實資料,原生本來就過期。`--fix` 拒絕、每日抽樣略過、Router 不因漂移退回原生。
|
||||||
|
- **Router**:上次檢查有漂移的 group 不路由,整個查詢維持原生;從未檢查的 group 照常路由(決策 2026-09-27:升級後不變慢)。
|
||||||
|
- **每日抽樣**:`wpdo_daily_health_check`,每 group 500 筆。
|
||||||
|
- **Hook Bus 關閉期間的寫入**:entity 從 disabled 進入 dual_write / shadow_read、或 Hook Bus flag 由關轉開時,60 秒後 `tmdo_drift_resync` 執行全量 `--fix`。不用 `post_modified_gmt` 增量,因為 WooCommerce 直接 SQL 寫 meta 不會更新它。
|
||||||
|
- **Auto-promoter** 以 `clean_since()`(2 天內乾淨的檢查)取代 shadow diff 數量;Promotion Guard 仍擋 HP / WC 站。
|
||||||
|
|
||||||
|
### 查詢
|
||||||
|
|
||||||
|
- 純排序搜尋(HivePress `_sort`、沒有路由篩選)在該 post type 每篇都有扁平列時改由扁平表驅動;Router 在 `wp_insert_post` 替新文章建空列,覆蓋率快取 15 分鐘。
|
||||||
|
|
||||||
## 後果
|
## 後果
|
||||||
|
|
||||||
**好處**
|
**好處**
|
||||||
@@ -89,7 +109,7 @@ HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `opti
|
|||||||
|
|
||||||
- **寫入開銷**:dual_write / shadow_read 每次 meta 寫入多一次原生讀取與一次扁平寫入。dev36 實測 HP listing 整條寫入鏈 +29%、WC product +127%(後者在 2.2 移除 WC 扁平化後消失)。
|
- **寫入開銷**:dual_write / shadow_read 每次 meta 寫入多一次原生讀取與一次扁平寫入。dev36 實測 HP listing 整條寫入鏈 +29%、WC product +127%(後者在 2.2 移除 WC 扁平化後消失)。
|
||||||
- **沒有讀取加速**:帶 key 的單筆讀取仍走原生(WordPress meta cache)。
|
- **沒有讀取加速**:帶 key 的單筆讀取仍走原生(WordPress meta cache)。
|
||||||
- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 的 `drift-check` 與重新啟用時的增量 backfill(E01 / E02)處理;在那之前,大量匯入後要重新 backfill。
|
- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 起由 `drift-check`(每日抽樣、Router 避開漂移中的 group)與重新啟用時的全量 `--fix` 處理;兩次抽樣之間的漂移仍可能短暫回傳過期結果,大量匯入後可手動執行 `drift-check <entity> --fix`。
|
||||||
- **decimal 欄位搭配 NUMERIC / DECIMAL**:原生先 CAST 成整數(`DECIMAL` 預設 (10,0)),扁平表精確比較,因此帶小數的值在區間邊界上可能不同。dev36 資料沒有這種值。
|
- **decimal 欄位搭配 NUMERIC / DECIMAL**:原生先 CAST 成整數(`DECIMAL` 預設 (10,0)),扁平表精確比較,因此帶小數的值在區間邊界上可能不同。dev36 資料沒有這種值。
|
||||||
- **text 欄位超過 255 字元**存 NULL,這類值的篩選結果會與原生不同(有 `text_overflow` 記錄)。
|
- **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。
|
- 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。
|
||||||
|
|||||||
@@ -296,6 +296,16 @@ class TMDO_Core {
|
|||||||
// v2.3.0 M6: daily health probe cron.
|
// v2.3.0 M6: daily health probe cron.
|
||||||
add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) );
|
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).
|
// v2.3.0 M6: daily snapshot prune cron (paired with health check).
|
||||||
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
|
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
|
||||||
|
|
||||||
|
|||||||
@@ -788,6 +788,7 @@ class TMDO_Installer {
|
|||||||
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
|
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
|
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_term_comment_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_post_stress_test_batch' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
|
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
|
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
|
||||||
@@ -991,6 +992,7 @@ class TMDO_Installer {
|
|||||||
'wpdo_health_snapshot_monthly',
|
'wpdo_health_snapshot_monthly',
|
||||||
'wpdo_post_shadow_verify',
|
'wpdo_post_shadow_verify',
|
||||||
'wpdo_term_comment_shadow_verify',
|
'wpdo_term_comment_shadow_verify',
|
||||||
|
'tmdo_drift_resync',
|
||||||
'wpdo_post_stress_test_batch',
|
'wpdo_post_stress_test_batch',
|
||||||
'wpdo_user_stress_test_batch',
|
'wpdo_user_stress_test_batch',
|
||||||
'wpdo_term_stress_test_batch',
|
'wpdo_term_stress_test_batch',
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ final class TMDO_Post_Shadow_Verifier {
|
|||||||
* is available.
|
* is available.
|
||||||
*
|
*
|
||||||
* @param string $post_type WP post_type to sample.
|
* @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 $flat_table Fully qualified flat table name.
|
||||||
* @param string[] $keys Meta keys to compare (each post checks all).
|
* @param string[] $keys Meta keys to compare (each post checks all).
|
||||||
* @param int $sample_size Number of posts to sample (capped to DB count).
|
* @param int $sample_size Number of posts to sample (capped to DB count).
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class TMDO_REST_API {
|
|||||||
array(
|
array(
|
||||||
'methods' => WP_REST_Server::READABLE,
|
'methods' => WP_REST_Server::READABLE,
|
||||||
'callback' => array( $this, 'get_listings' ),
|
'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',
|
'permission_callback' => '__return_true',
|
||||||
'args' => $this->listings_args(),
|
'args' => $this->listings_args(),
|
||||||
)
|
)
|
||||||
@@ -48,6 +50,8 @@ class TMDO_REST_API {
|
|||||||
array(
|
array(
|
||||||
'methods' => WP_REST_Server::READABLE,
|
'methods' => WP_REST_Server::READABLE,
|
||||||
'callback' => array( $this, 'get_listing' ),
|
'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',
|
'permission_callback' => '__return_true',
|
||||||
'args' => array(
|
'args' => array(
|
||||||
'id' => array(
|
'id' => array(
|
||||||
@@ -65,6 +69,7 @@ class TMDO_REST_API {
|
|||||||
array(
|
array(
|
||||||
'methods' => WP_REST_Server::READABLE,
|
'methods' => WP_REST_Server::READABLE,
|
||||||
'callback' => array( $this, 'get_stats' ),
|
'callback' => array( $this, 'get_stats' ),
|
||||||
|
// Public by design: view / engagement counters of a post viewable to the request.
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'args' => array(
|
'args' => array(
|
||||||
'id' => array(
|
'id' => array(
|
||||||
@@ -82,6 +87,8 @@ class TMDO_REST_API {
|
|||||||
array(
|
array(
|
||||||
'methods' => WP_REST_Server::CREATABLE,
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
'callback' => array( $this, 'post_view' ),
|
'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',
|
'permission_callback' => '__return_true',
|
||||||
'args' => array(
|
'args' => array(
|
||||||
'id' => array(
|
'id' => array(
|
||||||
|
|||||||
@@ -0,0 +1,532 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* TMDO_Drift_Check — read-only flat-vs-native comparison for every entity type.
|
||||||
|
*
|
||||||
|
* For each backfillable group and key, the flat column is compared with what
|
||||||
|
* native meta would put there (first native row → safe unserialize →
|
||||||
|
* TMDO_Type_Caster::to_db()):
|
||||||
|
*
|
||||||
|
* miss — native holds a non-empty value, the flat column is NULL or has no row
|
||||||
|
* diff — both hold a value and the values differ (numbers, JSON and
|
||||||
|
* serialized values are compared by value, not by formatting)
|
||||||
|
* extra — the flat column holds a value, native has no row for the key
|
||||||
|
* (a required column holding its default counts as absent); rows of
|
||||||
|
* another post type or of a deleted entity count too
|
||||||
|
*
|
||||||
|
* run() writes nothing except the per-group outcome in the wpdo_drift_status
|
||||||
|
* option. fix() rebuilds the drifted rows from native (native is never written).
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
* @since 1.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// phpcs:ignore WPDO.AntiEAV -- drift checker: must read raw meta to compare it with the flat tables
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Read-only checker: table and column names come from the registry / adapters, meta keys and ids go through prepare() or intval.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat-vs-native drift checker.
|
||||||
|
*/
|
||||||
|
final class TMDO_Drift_Check {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option holding the last outcome per entity / group.
|
||||||
|
*/
|
||||||
|
public const OPTION = 'wpdo_drift_status';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entities per group sampled by the daily check.
|
||||||
|
*/
|
||||||
|
public const CRON_SAMPLE = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single WP-Cron event rebuilding an entity's flat tables from native after
|
||||||
|
* writes bypassed them. Argument: the entity type.
|
||||||
|
*/
|
||||||
|
public const RESYNC_HOOK = 'tmdo_drift_resync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seconds before the scheduled rebuild, so the switch that triggered it
|
||||||
|
* finishes first.
|
||||||
|
*/
|
||||||
|
private const RESYNC_DELAY = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL regex for a plain decimal number, used to skip rows that obviously match.
|
||||||
|
* Exact matches compare BINARY: meta and flat tables may differ in collation,
|
||||||
|
* and a case-insensitive one would hide 'b' vs 'B'.
|
||||||
|
*/
|
||||||
|
private const SQL_NUMERIC = '^-?[0-9]+(\\\\.[0-9]+)?$';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare one entity type's flat tables with native meta.
|
||||||
|
*
|
||||||
|
* @param string $entity_type post | user | term | comment.
|
||||||
|
* @param string $group One group, or '' for every backfillable group.
|
||||||
|
* @param int $sample 0 checks every entity; N checks N random entities per group.
|
||||||
|
* @return array{entity:string, sample:int, groups:array<string, array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>}>, total:array{miss:int, diff:int, extra:int}}
|
||||||
|
* @throws InvalidArgumentException Unknown entity type or group.
|
||||||
|
*/
|
||||||
|
public static function run( string $entity_type, string $group = '', int $sample = 0 ): array {
|
||||||
|
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
|
||||||
|
|
||||||
|
$report = array(
|
||||||
|
'entity' => $entity_type,
|
||||||
|
'sample' => $sample,
|
||||||
|
'groups' => array(),
|
||||||
|
'total' => array(
|
||||||
|
'miss' => 0,
|
||||||
|
'diff' => 0,
|
||||||
|
'extra' => 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
$status = get_option( self::OPTION, array() );
|
||||||
|
$status = is_array( $status ) ? $status : array();
|
||||||
|
|
||||||
|
foreach ( $groups as $name ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
|
||||||
|
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result = self::check_group( $adapter, $entity_type, $name, $table, $sample );
|
||||||
|
unset( $result['ids'] );
|
||||||
|
|
||||||
|
$report['groups'][ $name ] = $result;
|
||||||
|
foreach ( array( 'miss', 'diff', 'extra' ) as $kind ) {
|
||||||
|
$report['total'][ $kind ] += $result[ $kind ];
|
||||||
|
}
|
||||||
|
$status[ $entity_type ][ $name ] = array(
|
||||||
|
'miss' => $result['miss'],
|
||||||
|
'diff' => $result['diff'],
|
||||||
|
'extra' => $result['extra'],
|
||||||
|
'sample' => $sample,
|
||||||
|
'checked_at' => gmdate( 'Y-m-d H:i:s' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( self::OPTION, $status );
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every backfillable group of the entity was checked within $max_age
|
||||||
|
* seconds (sampled or full) and none drifted. No record counts as unclean.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param int $max_age Seconds.
|
||||||
|
*/
|
||||||
|
public static function clean_since( string $entity_type, int $max_age ): bool {
|
||||||
|
$status = get_option( self::OPTION, array() );
|
||||||
|
$status = is_array( $status ) ? ( $status[ $entity_type ] ?? array() ) : array();
|
||||||
|
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
|
||||||
|
if ( empty( $groups ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach ( $groups as $group ) {
|
||||||
|
$last = $status[ $group ] ?? null;
|
||||||
|
if ( ! is_array( $last )
|
||||||
|
|| (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0
|
||||||
|
|| (int) strtotime( ( $last['checked_at'] ?? '' ) . ' UTC' ) < time() - $max_age ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily check (wpdo_daily_health_check): sample every entity type written
|
||||||
|
* to both native and flat (dual_write, shadow_read). In aeav_only flat is
|
||||||
|
* the source of truth and native is stale by design, so drift means nothing
|
||||||
|
* there. The outcome replaces the recorded status, so a group found
|
||||||
|
* drifting leaves the router at once.
|
||||||
|
*/
|
||||||
|
public static function cron_tick(): void {
|
||||||
|
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||||
|
if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) || ! TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
self::run( $entity_type, '', self::CRON_SAMPLE );
|
||||||
|
} catch ( InvalidArgumentException $e ) {
|
||||||
|
continue; // No adapter for this entity type on this site.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entities to rebuild after a mode change: one whose flat tables were not
|
||||||
|
* written (disabled) is written next to native again, and values written
|
||||||
|
* meanwhile are missing from flat.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $new_mode New mode.
|
||||||
|
* @param string $old_mode Previous mode.
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function resync_after_mode_change( string $entity_type, string $new_mode, string $old_mode ): array {
|
||||||
|
$keeps_native = array( TMDO_Mode_Manager::MODE_DUAL_WRITE, TMDO_Mode_Manager::MODE_SHADOW_READ );
|
||||||
|
return TMDO_Mode_Manager::MODE_DISABLED === $old_mode && in_array( $new_mode, $keeps_native, true ) ? array( $entity_type ) : array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entities to rebuild after the Hook Bus flag changes: no write reached
|
||||||
|
* flat while it was off. Every entity written to both native and flat
|
||||||
|
* (aeav_only keeps flat as the source of truth).
|
||||||
|
*
|
||||||
|
* @param mixed $old_value Previous flag value.
|
||||||
|
* @param mixed $value New flag value.
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function resync_after_hook_bus_change( $old_value, $value ): array {
|
||||||
|
if ( self::flag_on( $old_value ) || ! self::flag_on( $value ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$entities = array();
|
||||||
|
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||||
|
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
|
||||||
|
$entities[] = $entity_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `wpdo_bridge_mode_changed` handler.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $new_mode New mode.
|
||||||
|
* @param string $old_mode Previous mode.
|
||||||
|
*/
|
||||||
|
public static function on_mode_changed( string $entity_type, string $new_mode, string $old_mode ): void {
|
||||||
|
self::schedule_resync( self::resync_after_mode_change( $entity_type, $new_mode, $old_mode ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `update_option_wpdo_hook_bus_enabled` handler.
|
||||||
|
*
|
||||||
|
* @param mixed $old_value Previous flag value.
|
||||||
|
* @param mixed $value New flag value.
|
||||||
|
*/
|
||||||
|
public static function on_hook_bus_toggled( $old_value, $value ): void {
|
||||||
|
self::schedule_resync( self::resync_after_hook_bus_change( $old_value, $value ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `add_option_wpdo_hook_bus_enabled` handler: the flag is stored for the first time.
|
||||||
|
*
|
||||||
|
* @param string $option Option name.
|
||||||
|
* @param mixed $value Flag value.
|
||||||
|
*/
|
||||||
|
public static function on_hook_bus_added( $option, $value ): void {
|
||||||
|
unset( $option );
|
||||||
|
self::schedule_resync( self::resync_after_hook_bus_change( '0', $value ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RESYNC_HOOK handler. An entity that has meanwhile moved to aeav_only is
|
||||||
|
* left alone (fix() refuses).
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
*/
|
||||||
|
public static function run_scheduled_fix( string $entity_type ): void {
|
||||||
|
try {
|
||||||
|
$result = self::fix( $entity_type );
|
||||||
|
TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] );
|
||||||
|
} catch ( InvalidArgumentException | RuntimeException $e ) {
|
||||||
|
TMDO_Logger::info(
|
||||||
|
'drift_resync_skipped',
|
||||||
|
array(
|
||||||
|
'entity' => $entity_type,
|
||||||
|
'reason' => $e->getMessage(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule one rebuild per entity type (not twice while one is pending).
|
||||||
|
*
|
||||||
|
* @param string[] $entities Entity types.
|
||||||
|
*/
|
||||||
|
private static function schedule_resync( array $entities ): void {
|
||||||
|
foreach ( $entities as $entity_type ) {
|
||||||
|
if ( ! wp_next_scheduled( self::RESYNC_HOOK, array( $entity_type ) ) ) {
|
||||||
|
wp_schedule_single_event( time() + self::RESYNC_DELAY, self::RESYNC_HOOK, array( $entity_type ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a stored Hook Bus flag value means "on" (as TMDO_Hook_Bus_Bridge reads it).
|
||||||
|
*
|
||||||
|
* @param mixed $value Flag value.
|
||||||
|
*/
|
||||||
|
private static function flag_on( $value ): bool {
|
||||||
|
return '1' === (string) $value || true === $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild drifted flat rows from native meta, then check again.
|
||||||
|
*
|
||||||
|
* Rows of deleted entities (or, for post groups, of posts outside the
|
||||||
|
* group's post types) are deleted. Every other entity with drift in any key
|
||||||
|
* gets its whole row rebuilt from native; when native holds none of the
|
||||||
|
* group's keys the row is kept with its columns cleared. Row caches of the
|
||||||
|
* touched entities are invalidated. Native meta is never written.
|
||||||
|
*
|
||||||
|
* @param string $entity_type post | user | term | comment.
|
||||||
|
* @param string $group One group, or '' for every backfillable group.
|
||||||
|
* @return array{fixed:array{rebuilt:int, cleared:int, deleted_rows:int}, after:array<string, mixed>}
|
||||||
|
* @throws RuntimeException In aeav_only, where flat is the source of truth
|
||||||
|
* (and InvalidArgumentException via resolve() for an
|
||||||
|
* unknown entity type or group).
|
||||||
|
*/
|
||||||
|
public static function fix( string $entity_type, string $group = '' ): array {
|
||||||
|
global $wpdb;
|
||||||
|
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
|
||||||
|
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ) {
|
||||||
|
throw new RuntimeException( esc_html( $entity_type ) . ' is in aeav_only: flat is the source of truth and native meta is stale, so it cannot be rebuilt from native. Write flat back first (wp tmdo bridge-restore-native).' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $adapter->get_entity_id_column();
|
||||||
|
$fixed = array(
|
||||||
|
'rebuilt' => 0,
|
||||||
|
'cleared' => 0,
|
||||||
|
'deleted_rows' => 0,
|
||||||
|
);
|
||||||
|
foreach ( $groups as $name ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
|
||||||
|
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( self::stray_ids( $adapter, $entity_type, $name, $table ) as $eid ) {
|
||||||
|
$fixed['deleted_rows'] += (int) $wpdb->delete( $table, array( $id => $eid ), array( '%d' ) );
|
||||||
|
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( self::check_group( $adapter, $entity_type, $name, $table, 0 )['ids'] as $eid ) {
|
||||||
|
if ( TMDO_Entity_Migration_Engine::migrate_entity( $entity_type, $name, $eid ) ) {
|
||||||
|
++$fixed['rebuilt'];
|
||||||
|
} else {
|
||||||
|
self::clear_row( $entity_type, $name, $table, $id, $eid );
|
||||||
|
++$fixed['cleared'];
|
||||||
|
}
|
||||||
|
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'fixed' => $fixed,
|
||||||
|
'after' => self::run( $entity_type, $group ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter and group list for a run.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group One group, or '' for every backfillable group.
|
||||||
|
* @return array{0:TMDO_Entity_Adapter_Interface, 1:string[]}
|
||||||
|
* @throws InvalidArgumentException Unknown entity type or group.
|
||||||
|
*/
|
||||||
|
private static function resolve( string $entity_type, string $group ): array {
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||||
|
if ( ! $adapter ) {
|
||||||
|
throw new InvalidArgumentException( 'Unknown entity type: ' . esc_html( $entity_type ) );
|
||||||
|
}
|
||||||
|
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
|
||||||
|
if ( '' !== $group ) {
|
||||||
|
if ( ! in_array( $group, $groups, true ) ) {
|
||||||
|
throw new InvalidArgumentException( 'Unknown or orphan ' . esc_html( $entity_type ) . ' group: ' . esc_html( $group ) );
|
||||||
|
}
|
||||||
|
$groups = array( $group );
|
||||||
|
}
|
||||||
|
return array( $adapter, $groups );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare every key of one group.
|
||||||
|
*
|
||||||
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $table Group flat table.
|
||||||
|
* @param int $sample 0, or the number of random entities.
|
||||||
|
* @return array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>, ids:int[]}
|
||||||
|
*/
|
||||||
|
private static function check_group( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table, int $sample ): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$meta = $adapter->get_native_meta_table();
|
||||||
|
$id = $adapter->get_entity_id_column();
|
||||||
|
$mid = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
|
||||||
|
$scope = self::scope_sql( $adapter, $entity_type, $group, $sample );
|
||||||
|
$result = array(
|
||||||
|
'miss' => 0,
|
||||||
|
'diff' => 0,
|
||||||
|
'extra' => 0,
|
||||||
|
'keys' => array(),
|
||||||
|
'ids' => array(),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
|
||||||
|
$key = (string) $field['key'];
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||||
|
$type = $field['type'] ?? 'text';
|
||||||
|
|
||||||
|
// First native row per entity — what a single get_*_meta() read returns.
|
||||||
|
$native = $wpdb->prepare( "SELECT m.`{$id}` AS eid, m.meta_value AS v FROM `{$meta}` m JOIN ( SELECT `{$id}`, MIN(`{$mid}`) AS mid FROM `{$meta}` WHERE meta_key = %s GROUP BY `{$id}` ) x ON x.mid = m.`{$mid}` WHERE 1=1", $key )
|
||||||
|
. str_replace( '{id}', "m.`{$id}`", $scope );
|
||||||
|
|
||||||
|
// Text beyond the column width is stored as NULL by design (Type_Caster::fit_text).
|
||||||
|
$fits = 'text' === $type ? ' AND CHAR_LENGTH(n.v) <= ' . TMDO_Type_Caster::TEXT_MAX_CHARS : '';
|
||||||
|
$miss = array_map( 'intval', $wpdb->get_col( "SELECT n.eid FROM ( {$native} ) n LEFT JOIN `{$table}` f ON f.`{$id}` = n.eid WHERE LENGTH(n.v) > 0 AND f.`{$col}` IS NULL{$fits}" ) );
|
||||||
|
|
||||||
|
$candidates = $wpdb->get_results(
|
||||||
|
"SELECT n.eid, n.v, f.`{$col}` AS flat FROM ( {$native} ) n JOIN `{$table}` f ON f.`{$id}` = n.eid
|
||||||
|
WHERE f.`{$col}` IS NOT NULL AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY n.v
|
||||||
|
OR ( n.v REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6)) ) )",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
$diff = array();
|
||||||
|
foreach ( (array) $candidates as $row ) {
|
||||||
|
if ( ! self::same( (string) $row['v'], (string) $row['flat'], $field ) ) {
|
||||||
|
$diff[] = (int) $row['eid'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$absent = $wpdb->prepare( "NOT EXISTS ( SELECT 1 FROM `{$meta}` m WHERE m.`{$id}` = f.`{$id}` AND m.meta_key = %s )", $key );
|
||||||
|
if ( ! empty( $field['required'] ) ) {
|
||||||
|
$default = (string) TMDO_Type_Caster::to_db( null, $field );
|
||||||
|
$absent .= $wpdb->prepare(
|
||||||
|
" AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY %s OR ( %s REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(%s AS DECIMAL(30,6)) ) )",
|
||||||
|
$default,
|
||||||
|
$default,
|
||||||
|
$default
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Every flat row counts, including rows of other post types or deleted
|
||||||
|
// entities; only sample mode narrows the rows.
|
||||||
|
$extra = array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f WHERE f.`{$col}` IS NOT NULL AND {$absent}" . ( $sample > 0 ? str_replace( '{id}', "f.`{$id}`", $scope ) : '' ) ) );
|
||||||
|
|
||||||
|
$counts = array(
|
||||||
|
'miss' => count( $miss ),
|
||||||
|
'diff' => count( $diff ),
|
||||||
|
'extra' => count( $extra ),
|
||||||
|
);
|
||||||
|
$result['keys'][ $key ] = $counts;
|
||||||
|
$result['miss'] += $counts['miss'];
|
||||||
|
$result['diff'] += $counts['diff'];
|
||||||
|
$result['extra'] += $counts['extra'];
|
||||||
|
$result['ids'] = array_merge( $result['ids'], $miss, $diff, $extra );
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['ids'] = array_values( array_unique( $result['ids'] ) );
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat rows whose entity no longer exists or, for post groups, whose post
|
||||||
|
* is outside the group's post types.
|
||||||
|
*
|
||||||
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $table Group flat table.
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private static function stray_ids( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table ): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$id = $adapter->get_entity_id_column();
|
||||||
|
$primary = $adapter->get_primary_table();
|
||||||
|
$pk = $adapter->get_primary_id_column();
|
||||||
|
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
|
||||||
|
$type_sql = $post_types ? " AND p.post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')" : '';
|
||||||
|
|
||||||
|
return array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f LEFT JOIN `{$primary}` p ON p.`{$pk}` = f.`{$id}`{$type_sql} WHERE p.`{$pk}` IS NULL" ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear a row's columns: native holds none of the group's keys. The row is
|
||||||
|
* kept (NULL-only rows are not deleted, as the router may join on them);
|
||||||
|
* required columns go back to their default.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $table Group flat table.
|
||||||
|
* @param string $id Entity id column.
|
||||||
|
* @param int $eid Entity id.
|
||||||
|
*/
|
||||||
|
private static function clear_row( string $entity_type, string $group, string $table, string $id, int $eid ): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$data = array();
|
||||||
|
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
|
||||||
|
$data[ TMDO_Schema_Manager::sanitize_column_name( (string) $field['key'] ) ] = TMDO_Type_Caster::to_db( null, $field );
|
||||||
|
}
|
||||||
|
$wpdb->update( $table, $data, array( $id => $eid ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL restricting the checked entities: the group's post types, and in
|
||||||
|
* sample mode a random set of entity ids. `{id}` stands for the id column.
|
||||||
|
*
|
||||||
|
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param int $sample 0, or the number of random entities.
|
||||||
|
*/
|
||||||
|
private static function scope_sql( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, int $sample ): string {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
|
||||||
|
$type_sql = $post_types
|
||||||
|
? " post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')"
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if ( $sample <= 0 ) {
|
||||||
|
return $type_sql ? " AND {id} IN ( SELECT ID FROM {$wpdb->posts} WHERE{$type_sql} )" : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$primary = $adapter->get_primary_table();
|
||||||
|
$pk = $adapter->get_primary_id_column();
|
||||||
|
$ids = $wpdb->get_col( "SELECT `{$pk}` FROM `{$primary}`" . ( $type_sql ? " WHERE{$type_sql}" : '' ) . ' ORDER BY RAND() LIMIT ' . (int) $sample );
|
||||||
|
return ' AND {id} IN (' . ( $ids ? implode( ',', array_map( 'intval', $ids ) ) : '0' ) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a flat value is what native meta would store.
|
||||||
|
*
|
||||||
|
* @param string $native Raw native meta_value.
|
||||||
|
* @param string $flat Flat column value.
|
||||||
|
* @param array<string, mixed> $field Registered field definition.
|
||||||
|
*/
|
||||||
|
private static function same( string $native, string $flat, array $field ): bool {
|
||||||
|
$type = $field['type'] ?? 'text';
|
||||||
|
$value = TMDO_Safe_Unserialize::run( $native );
|
||||||
|
if ( 'text' === $type && is_scalar( $value ) && mb_strlen( (string) $value ) > TMDO_Type_Caster::TEXT_MAX_CHARS ) {
|
||||||
|
return false; // Belongs in flat as NULL, yet this row holds a value.
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = TMDO_Type_Caster::to_db( $value, $field );
|
||||||
|
if ( null === $expected ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ( 'json' === $type ) {
|
||||||
|
// Key order and int-vs-float formatting are not drift.
|
||||||
|
return json_decode( $flat, true ) == json_decode( (string) $expected, true ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
|
||||||
|
}
|
||||||
|
if ( is_numeric( $expected ) && is_numeric( $flat ) ) {
|
||||||
|
return abs( (float) $expected - (float) $flat ) < 0.000001;
|
||||||
|
}
|
||||||
|
return (string) $expected === $flat;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ class TMDO_Health_Cron {
|
|||||||
}
|
}
|
||||||
} catch ( Throwable $e ) {
|
} catch ( Throwable $e ) {
|
||||||
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check.
|
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check.
|
||||||
error_log( '[WPDO] Module detector exception in health cron: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
TMDO_Logger::error( 'health_cron', 'module_detector', $e->getMessage() );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* 判斷條件(皆滿足才觸發):
|
* 判斷條件(皆滿足才觸發):
|
||||||
* 1. 當前 mode = shadow_read
|
* 1. 當前 mode = shadow_read
|
||||||
* 2. 進入 shadow_read 距今 ≥ `min_days`
|
* 2. 進入 shadow_read 距今 ≥ `min_days`
|
||||||
* 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0
|
* 3. 該 entity 每個群組最近一次 drift-check(2 天內,抽樣或全量)皆無漂移
|
||||||
|
* (1.2.0 起取代 shadow_diffs 筆數;無紀錄不升級)
|
||||||
*
|
*
|
||||||
* 這符合「穩定期沒問題就進 production」的安全遷移原則。
|
* 這符合「穩定期沒問題就進 production」的安全遷移原則。
|
||||||
* 若 diff 出現,規則永不觸發;人工介入即可。
|
* 若 diff 出現,規則永不觸發;人工介入即可。
|
||||||
@@ -35,6 +36,9 @@ final class TMDO_Auto_Promoter {
|
|||||||
|
|
||||||
public const CRON_HOOK = 'wpdo_auto_promote_check';
|
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 {
|
public static function init(): void {
|
||||||
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
|
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
|
||||||
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
|
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
|
||||||
@@ -82,7 +86,7 @@ final class TMDO_Auto_Promoter {
|
|||||||
/**
|
/**
|
||||||
* 評估單一 entity 是否可升級。
|
* 評估單一 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 {
|
public static function check_entity( string $type, int $min_days, array $entered_all ): string {
|
||||||
$current = TMDO_Mode_Manager::get( $type );
|
$current = TMDO_Mode_Manager::get( $type );
|
||||||
@@ -95,14 +99,13 @@ final class TMDO_Auto_Promoter {
|
|||||||
return 'not_ready';
|
return 'not_ready';
|
||||||
}
|
}
|
||||||
|
|
||||||
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity();
|
if ( ! TMDO_Drift_Check::clean_since( $type, self::DRIFT_MAX_AGE ) ) {
|
||||||
$count = (int) ( $diff_counts[ $type ] ?? 0 );
|
return 'drift_not_clean';
|
||||||
if ( $count > 0 ) {
|
|
||||||
return 'has_diff';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)
|
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)。第 4 參數原為
|
||||||
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count );
|
// shadow diff 筆數;1.2.0 起改看 drift,固定傳 0。
|
||||||
|
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, 0 );
|
||||||
if ( ! $should ) {
|
if ( ! $should ) {
|
||||||
return 'vetoed';
|
return 'vetoed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,13 +144,14 @@ final class TMDO_Entity_Migration_Engine {
|
|||||||
}
|
}
|
||||||
} catch ( \Throwable $e ) {
|
} catch ( \Throwable $e ) {
|
||||||
++$stats['errors'];
|
++$stats['errors'];
|
||||||
error_log(
|
TMDO_Logger::error(
|
||||||
sprintf(
|
'migration_engine',
|
||||||
'[UAE Migration] Error %s/%s ID=%d: %s',
|
'migrate_group',
|
||||||
$entity_type,
|
$e->getMessage(),
|
||||||
$group_name,
|
array(
|
||||||
$entity_id,
|
'entity_type' => $entity_type,
|
||||||
$e->getMessage()
|
'group' => $group_name,
|
||||||
|
'entity_id' => $entity_id,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -182,6 +183,28 @@ final class TMDO_Entity_Migration_Engine {
|
|||||||
return $stats;
|
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
|
* 遷移單一 entity 的所有 meta
|
||||||
*/
|
*/
|
||||||
@@ -243,6 +266,18 @@ final class TMDO_Entity_Migration_Engine {
|
|||||||
return false;
|
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 ) {
|
if ( $dry_run ) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -349,7 +384,16 @@ final class TMDO_Entity_Migration_Engine {
|
|||||||
++$batch_migrated;
|
++$batch_migrated;
|
||||||
}
|
}
|
||||||
} catch ( \Throwable $e ) {
|
} 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;
|
$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 ) );
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
// 斷點管理
|
// 斷點管理
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ final class TMDO_Shadow_Diff_Logger {
|
|||||||
/** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */
|
/** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */
|
||||||
public const RATELIMIT_WINDOW = 300;
|
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 {
|
public static function table_name(): string {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
return $wpdb->prefix . 'wpdo_shadow_diffs';
|
return $wpdb->prefix . 'wpdo_shadow_diffs';
|
||||||
@@ -69,7 +77,7 @@ final class TMDO_Shadow_Diff_Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 不一致 → 檢查 rate limit
|
// 不一致 → 檢查 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +191,21 @@ final class TMDO_Shadow_Diff_Logger {
|
|||||||
return false;
|
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 row
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ final class TMDO_Type_Caster {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */
|
/** '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
|
* Keep a text value only if it fits its VARCHAR(255) column; otherwise
|
||||||
|
|||||||
@@ -2,19 +2,18 @@
|
|||||||
/**
|
/**
|
||||||
* Post entity field registration for WP Data Optimizer (v2.9.1).
|
* 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.
|
* wp_postmeta rows that previously bypassed any flat-table strategy.
|
||||||
* All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV.
|
* All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV.
|
||||||
*
|
*
|
||||||
* Groups → flat tables (post_type targets):
|
* Groups → flat tables (post_type targets):
|
||||||
* wp_core → wp_wpdo_post_wp_core (cross post_type)
|
* wp_core → wp_wpdo_post_wp_core (cross post_type)
|
||||||
* attachment → wp_wpdo_post_attachment (attachment)
|
* 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)
|
* nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item)
|
||||||
*
|
*
|
||||||
* Pattern mirrors TMDO_Member_Fields::register(). Called from
|
* Pattern mirrors TMDO_Member_Fields::register(). Called from
|
||||||
* TMDO_Core::run() before the wpdo_register_entity_fields action fires;
|
* 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).
|
* via process_pending_migrations() (idempotent via schema_hash compare).
|
||||||
*
|
*
|
||||||
* Keys not registered here (HivePress dynamic attrs, WPCS legacy keys,
|
* 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 {
|
final class TMDO_Post_Fields {
|
||||||
|
|
||||||
@@ -49,7 +51,6 @@ final class TMDO_Post_Fields {
|
|||||||
*/
|
*/
|
||||||
private const GROUP_POST_TYPES = array(
|
private const GROUP_POST_TYPES = array(
|
||||||
'attachment' => 'attachment',
|
'attachment' => 'attachment',
|
||||||
'wc_product' => 'product',
|
|
||||||
'nav_menu_item' => 'nav_menu_item',
|
'nav_menu_item' => 'nav_menu_item',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -104,7 +105,6 @@ final class TMDO_Post_Fields {
|
|||||||
|
|
||||||
self::register_wp_core_group();
|
self::register_wp_core_group();
|
||||||
self::register_attachment_group();
|
self::register_attachment_group();
|
||||||
self::register_wc_product_group();
|
|
||||||
self::register_nav_menu_item_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).
|
* Nav menu item meta keys (post_type=nav_menu_item).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -99,11 +99,12 @@ final class TMDO_Post_Migration {
|
|||||||
/**
|
/**
|
||||||
* Bulk SQL pivot for one group: select managed keys from wp_postmeta,
|
* 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
|
* 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}
|
* @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.
|
* @throws RuntimeException When the pivot SQL fails.
|
||||||
*/
|
*/
|
||||||
public static function backfill_group( string $group_name ): array {
|
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 )
|
'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 );
|
$flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name );
|
||||||
|
|
||||||
// Introspect target table columns so we only pivot fields that actually
|
// 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.
|
// 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();
|
$columns = array();
|
||||||
$cases = array();
|
$cases = array();
|
||||||
$update_parts = array();
|
$update_parts = array();
|
||||||
foreach ( $fields as $field ) {
|
foreach ( $fields as $field ) {
|
||||||
$type = $field['type'] ?? 'text';
|
|
||||||
if ( 'json' === $type ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
||||||
if ( ! isset( $existing_cols[ $col ] ) ) {
|
if ( ! isset( $existing_cols[ $col ] ) ) {
|
||||||
continue; // Column not present in this table — skip silently.
|
continue; // Column not present in this table — skip silently.
|
||||||
@@ -536,6 +553,17 @@ final class TMDO_Post_Migration {
|
|||||||
// Helpers
|
// 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.
|
* All meta_keys registered for any post entity group.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class TMDO_Post_Query_Router {
|
|||||||
/** Object cache group for found-rows counts. */
|
/** Object cache group for found-rows counts. */
|
||||||
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
|
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
|
||||||
|
|
||||||
|
/** 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. */
|
/** 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' );
|
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( 'split_the_query', array( $this, 'split_the_query' ), 10, 4 );
|
||||||
add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 );
|
add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 );
|
||||||
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
|
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
|
||||||
|
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();
|
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.
|
* 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;
|
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
|
* 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
|
* Native casts meta_value by the clause type (CHAR when absent); flat
|
||||||
* columns are typed. Routed: EXISTS / NOT EXISTS; string columns with
|
* 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 )
|
&& in_array( $compare, array( '=', '!=', '<>', 'IN', 'NOT IN' ), true )
|
||||||
&& array() === array_filter( (array) $value, static fn( $v ) => ! is_scalar( $v ) || (string) (int) $v !== (string) $v );
|
&& 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,14 +699,25 @@ class TMDO_Post_Query_Router {
|
|||||||
private static function flat_first( string $request, \WP_Query $query ): string {
|
private static function flat_first( string $request, \WP_Query $query ): string {
|
||||||
$routed = $query->get( self::QUERY_VAR );
|
$routed = $query->get( self::QUERY_VAR );
|
||||||
$sort = $query->get( self::SORT_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;
|
return $request;
|
||||||
}
|
}
|
||||||
$post_type = (string) array_key_first( $routed );
|
if ( empty( $routed ) || ! is_array( $routed ) ) {
|
||||||
$groups = array_unique( array_column( $routed[ $post_type ], 'group' ) );
|
// Sort only: the LEFT JOIN keeps posts without a flat row, so the
|
||||||
$compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) );
|
// flat table may drive only when every post of the type has one.
|
||||||
if ( empty( $sort ) || 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) {
|
$post_type = (string) $sort['post_type'];
|
||||||
return $request;
|
$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 ) {
|
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 ) ) ) {
|
if ( ! empty( $query->get( $var ) ) ) {
|
||||||
@@ -622,8 +729,8 @@ class TMDO_Post_Query_Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $groups[0] );
|
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
|
||||||
$alias = self::table_alias( $post_type, $groups[0] );
|
$alias = self::table_alias( $post_type, $group );
|
||||||
$on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
$on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
||||||
$join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}";
|
$join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}";
|
||||||
if ( 1 !== substr_count( $request, $join ) ) {
|
if ( 1 !== substr_count( $request, $join ) ) {
|
||||||
@@ -639,6 +746,13 @@ class TMDO_Post_Query_Router {
|
|||||||
if ( 1 !== $replaced ) {
|
if ( 1 !== $replaced ) {
|
||||||
return $request;
|
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.
|
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id.
|
||||||
$flat_first = preg_replace_callback(
|
$flat_first = preg_replace_callback(
|
||||||
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
|
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
|
||||||
|
|||||||
+13
-1
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
|
|||||||
Requires at least: 6.0
|
Requires at least: 6.0
|
||||||
Tested up to: 6.9
|
Tested up to: 6.9
|
||||||
Requires PHP: 8.1
|
Requires PHP: 8.1
|
||||||
Stable tag: 1.1.1
|
Stable tag: 1.2.0
|
||||||
License: GPLv2 or later
|
License: GPLv2 or later
|
||||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
|
||||||
@@ -107,6 +107,18 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
|
|||||||
|
|
||||||
== Changelog ==
|
== Changelog ==
|
||||||
|
|
||||||
|
= 1.2.0 =
|
||||||
|
* `wp tmdo drift-check <entity> [--group] [--sample] [--fix]`: reports flat-vs-native drift per key (miss / diff / extra) and rebuilds drifted flat rows from native; native meta is never written, and aeav_only sites are refused
|
||||||
|
* The post query router keeps a query native while one of its groups is recorded as drifting; a daily sample (500 entities per group) on the existing health-check event keeps the record current
|
||||||
|
* Switching an entity from disabled to dual_write / shadow_read, or turning the Hook Bus back on, schedules a full drift fix 60 s later (`tmdo_drift_resync`)
|
||||||
|
* Auto-promotion requires a clean drift check within 2 days; shadow_read diffs are capped at 20 per key per hour
|
||||||
|
* `wp tmdo bridge-mode <entity> <mode>` (under the Promotion Guard); `member-backfill --all`; post / user backfill groups come from the registry, json groups included
|
||||||
|
* WooCommerce: the `post/wc_product` group is no longer registered — product meta stays native (retire existing tables with `bridge-restore-native post --group=wc_product --retire`)
|
||||||
|
* Sort-only searches drive from the flat table when every post of the type has a flat row (HivePress rating sort ~3.3 s → ~0.5–0.85 s on 100k listings)
|
||||||
|
* `wp tmdo cleanup-hp-transients --options` removes HivePress AddOn transients left in wp_options
|
||||||
|
* Fixed: entity backfill invented default values (0) for keys with no native row; doctor, term-comment-diagnose and bridge-status false alarms; member-cutover fatal on a refused transition
|
||||||
|
* Admin output escaping and logging clean-ups from the 2.1 audit
|
||||||
|
|
||||||
= 1.1.1 =
|
= 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 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
|
* Security: the listings endpoint no longer returns whole rows when no field is public, and only filters / sorts on public columns
|
||||||
|
|||||||
+7
-4
@@ -506,10 +506,12 @@ if ( ! function_exists( 'register_rest_route' ) ) {
|
|||||||
// WP_CLI stub (needed before loading CLI classes).
|
// WP_CLI stub (needed before loading CLI classes).
|
||||||
if ( ! class_exists( 'WP_CLI' ) ) {
|
if ( ! class_exists( 'WP_CLI' ) ) {
|
||||||
class WP_CLI {
|
class WP_CLI {
|
||||||
public static function log( string $msg ): void {}
|
// Output is kept in $GLOBALS['_wp_cli_output'] so CLI tests can assert it.
|
||||||
public static function warning( string $msg ): void {}
|
public static function log( string $msg ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
|
||||||
public static function success( string $msg ): void {}
|
public static function line( string $msg = '' ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
|
||||||
public static function error( string $msg ): void {}
|
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 {}
|
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-schema-manager.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.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/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-entity-health.php';
|
||||||
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
|
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
|
||||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
|
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `wp tmdo cleanup-hp-transients --options` (2.2 PR-080): the HivePress AddOn
|
||||||
|
* moved HivePress's per-post transients out of wp_postmeta into wp_options
|
||||||
|
* (`_transient_wpdo_hp_pm_*`), where they piled up — 1.15 M rows on dev36. Since
|
||||||
|
* PR-081 they live in the object cache when there is one; this removes the
|
||||||
|
* rows left behind. Works without the AddOn loaded (as in core CI).
|
||||||
|
*/
|
||||||
|
class CleanupHpTransientsTest extends TestCase {
|
||||||
|
|
||||||
|
private const OPTIONS = 'wp_itest_options';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
require_once WPDO_PLUGIN_DIR . 'cli/class-tmdo-cli-post.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::OPTIONS . '`' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::OPTIONS . "` ( option_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, option_name VARCHAR(191) NOT NULL DEFAULT '', option_value LONGTEXT NOT NULL, autoload VARCHAR(20) NOT NULL DEFAULT 'yes', PRIMARY KEY (option_id), UNIQUE KEY option_name (option_name) ) ENGINE=InnoDB" );
|
||||||
|
for ( $i = 1; $i <= 5; $i++ ) {
|
||||||
|
$wpdb->insert( self::OPTIONS, array( 'option_name' => "_transient_wpdo_hp_pm_{$i}_abc", 'option_value' => 'v', 'autoload' => 'no' ) );
|
||||||
|
$wpdb->insert( self::OPTIONS, array( 'option_name' => "_transient_timeout_wpdo_hp_pm_{$i}_abc", 'option_value' => '1', 'autoload' => 'no' ) );
|
||||||
|
}
|
||||||
|
foreach ( array( 'siteurl', '_transient_other_plugin', '_transient_wpdo_hp_pmx', 'wpdo_hp_pm_1_abc' ) as $name ) {
|
||||||
|
$wpdb->insert( self::OPTIONS, array( 'option_name' => $name, 'option_value' => 'keep' ) );
|
||||||
|
}
|
||||||
|
$GLOBALS['_wp_cli_output'] = array();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::OPTIONS . '`' );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function remaining(): array {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->get_col( 'SELECT option_name FROM `' . self::OPTIONS . '` ORDER BY option_name' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dry_run_counts_the_rows_and_deletes_nothing(): void {
|
||||||
|
( new TMDO_CLI_Post() )->cleanup_hp_transients( array(), array( 'options' => true, 'dry-run' => true ) );
|
||||||
|
|
||||||
|
$this->assertCount( 14, $this->remaining() );
|
||||||
|
$this->assertStringContainsString( '10 HivePress transient row(s) in wp_options', implode( "\n", $GLOBALS['_wp_cli_output'] ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_confirm_deletes_only_the_hivepress_rows_in_batches(): void {
|
||||||
|
( new TMDO_CLI_Post() )->cleanup_hp_transients( array(), array( 'options' => true, 'confirm' => true, 'batch' => '3' ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( '_transient_other_plugin', '_transient_wpdo_hp_pmx', 'siteurl', 'wpdo_hp_pm_1_abc' ), $this->remaining() );
|
||||||
|
$this->assertStringContainsString( 'Deleted 10 HivePress transient row(s) from wp_options', implode( "\n", $GLOBALS['_wp_cli_output'] ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `wp tmdo bridge-status` progress (2.2 PR-076): eav_ids of a post group that
|
||||||
|
* belongs to some post types counted every post holding one of its keys, so a
|
||||||
|
* fully backfilled group showed less than 100%.
|
||||||
|
*
|
||||||
|
* flat_rows counted rows without any value too (wp_core holds an all-NULL row
|
||||||
|
* for every post): dev36 showed wp_core at 15306%.
|
||||||
|
*
|
||||||
|
* Fixture: group bs_grp for hp_listing only; bs_price on post 1 (hp_listing)
|
||||||
|
* and post 2 (a plain post); flat rows for post 1 (a value) and post 3
|
||||||
|
* (hp_listing, all NULL).
|
||||||
|
*/
|
||||||
|
class CliBridgeStatusTest extends TestCase {
|
||||||
|
|
||||||
|
private const POSTS = 'wp_itest_posts';
|
||||||
|
private const POSTMETA = 'wp_itest_postmeta';
|
||||||
|
private const FLAT = 'wp_itest_wpdo_post_bs_grp';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
'cli/class-tmdo-cli-v2.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once WPDO_PLUGIN_DIR . $file;
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'WP_CLI\Utils\format_items' ) ) {
|
||||||
|
eval( 'namespace WP_CLI\Utils; function format_items( $format, $items, $fields ) { $GLOBALS["_wp_cli_items"] = $items; }' ); // phpcs:ignore Squiz.PHP.Eval -- test-only WP-CLI stub.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::POSTS . "` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_type VARCHAR(20) NOT NULL DEFAULT 'post', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::POSTMETA . '` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::FLAT . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, bs_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->insert( self::POSTS, array( 'ID' => 1, 'post_type' => 'hp_listing' ) );
|
||||||
|
$wpdb->insert( self::POSTS, array( 'ID' => 2, 'post_type' => 'post' ) );
|
||||||
|
$wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => 'bs_price', 'meta_value' => '10' ) );
|
||||||
|
$wpdb->insert( self::POSTMETA, array( 'post_id' => 2, 'meta_key' => 'bs_price', 'meta_value' => '20' ) );
|
||||||
|
$wpdb->insert( self::FLAT, array( 'post_id' => 1, 'bs_price' => 10 ) );
|
||||||
|
$wpdb->insert( self::POSTS, array( 'ID' => 3, 'post_type' => 'hp_listing' ) );
|
||||||
|
$wpdb->insert( self::FLAT, array( 'post_id' => 3 ) );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'post' => 'dual_write' ) );
|
||||||
|
$GLOBALS['_wp_cli_items'] = array();
|
||||||
|
$GLOBALS['_wp_cli_output'] = array();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'bs_grp', array( array( 'key' => 'bs_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( self::POSTS, self::POSTMETA, self::FLAT ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_counts_only_flat_rows_that_hold_a_value_and_posts_of_the_groups_types(): void {
|
||||||
|
( new TMDO_CLI_V2() )->bridge_status( array(), array() );
|
||||||
|
|
||||||
|
$row = array_values( array_filter( $GLOBALS['_wp_cli_items'], static fn( $r ) => 'bs_grp' === $r['group'] ) )[0];
|
||||||
|
$this->assertSame( 1, $row['flat_rows'] );
|
||||||
|
$this->assertSame( 1, $row['eav_ids'] );
|
||||||
|
$this->assertSame( '100%', $row['progress_%'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `wp tmdo member-backfill` takes its groups from the registry (2.2 PR-068).
|
||||||
|
*
|
||||||
|
* Before: a hard-coded VALID_GROUPS list (with the never-registered `sso`)
|
||||||
|
* rejected groups registered by add-ons, and there was no way to backfill
|
||||||
|
* every user group at once — R-2.1 had to fall back to `wp eval`.
|
||||||
|
*
|
||||||
|
* Fixture (user entity): groups cb_one (cb_a) and cb_two (cb_b).
|
||||||
|
*/
|
||||||
|
class CliMemberBackfillTest extends TestCase {
|
||||||
|
|
||||||
|
private const ONE = 'wp_itest_wpdo_user_cb_one';
|
||||||
|
private const TWO = 'wp_itest_wpdo_user_cb_two';
|
||||||
|
private const STATUS = 'wp_itest_wpdo_migration_status';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-user.php',
|
||||||
|
'cli/class-tmdo-cli-member.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once WPDO_PLUGIN_DIR . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::ONE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::TWO . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_b VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_a', 'meta_value' => 'one' ) );
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_b', 'meta_value' => 'two' ) );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'user', 'cb_one', array( array( 'key' => 'cb_a', 'type' => 'text' ) ) );
|
||||||
|
WPDO_Entity_Registry::register_group( 'user', 'cb_two', array( array( 'key' => 'cb_b', 'type' => 'text' ) ) );
|
||||||
|
WPDO_Schema_Manager::flush_table_exists_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( $wpdb->usermeta, self::ONE, self::TWO, self::STATUS ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flat( string $table, string $col ): ?string {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->get_var( "SELECT `{$col}` FROM `{$table}` WHERE user_id = 1" );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_all_backfills_every_registered_user_group(): void {
|
||||||
|
( new TMDO_CLI_Member() )->member_backfill( array(), array( 'all' => true ) );
|
||||||
|
|
||||||
|
$this->assertSame( 'one', $this->flat( self::ONE, 'cb_a' ) );
|
||||||
|
$this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_group_accepts_any_registered_user_group(): void {
|
||||||
|
( new TMDO_CLI_Member() )->member_backfill( array(), array( 'group' => 'cb_two' ) );
|
||||||
|
|
||||||
|
$this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) );
|
||||||
|
$this->assertNull( $this->flat( self::ONE, 'cb_a' ), 'only the requested group' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_backfillable_groups_leave_out_restored_orphans(): void {
|
||||||
|
WPDO_Entity_Registry::restore_group( 'user', 'cb_orphan', array( array( 'key' => 'cb_orphan_key', 'type' => 'text' ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'cb_one', 'cb_two' ), TMDO_Entity_Migration_Engine::backfillable_groups( 'user' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `wp tmdo doctor` false alarms (2.2 PR-076).
|
||||||
|
*
|
||||||
|
* The user flat tables were a hard-coded list including wpdo_user_sso, a group
|
||||||
|
* never registered: doctor reported it MISSING and failed on every site. The
|
||||||
|
* tables now come from the registry; doctor also reports Promotion Guard
|
||||||
|
* violations and the recorded drift status.
|
||||||
|
*/
|
||||||
|
class DoctorTest extends TestCase {
|
||||||
|
|
||||||
|
private const HAVE = 'wp_itest_wpdo_user_dt_have';
|
||||||
|
private const CORE = array( 'wp_itest_wpdo_migration_status', 'wp_itest_wpdo_user_points_ledger' );
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-user.php',
|
||||||
|
'includes/engine/class-tmdo-promotion-guard.php',
|
||||||
|
'cli/class-tmdo-cli.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once WPDO_PLUGIN_DIR . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
foreach ( array_merge( array( self::HAVE ), self::CORE ) as $table ) {
|
||||||
|
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINE=InnoDB" );
|
||||||
|
}
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$GLOBALS['_wp_cli_output'] = array();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'user', 'dt_have', array( array( 'key' => 'dt_a', 'type' => 'text' ) ) );
|
||||||
|
WPDO_Schema_Manager::flush_table_exists_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array_merge( array( self::HAVE ), self::CORE ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cli_output(): string {
|
||||||
|
return implode( "\n", $GLOBALS['_wp_cli_output'] ?? array() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_entity_tables_come_from_the_registry(): void {
|
||||||
|
$ok = TMDO_CLI::check_entity_tables();
|
||||||
|
|
||||||
|
$this->assertTrue( $ok, $this->cli_output() );
|
||||||
|
$this->assertStringContainsString( '[OK] ' . self::HAVE, $this->cli_output() );
|
||||||
|
$this->assertStringNotContainsString( 'wpdo_user_sso', $this->cli_output() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_registered_group_without_its_table_is_reported(): void {
|
||||||
|
WPDO_Entity_Registry::register_group( 'user', 'dt_missing', array( array( 'key' => 'dt_b', 'type' => 'text' ) ) );
|
||||||
|
|
||||||
|
$ok = TMDO_CLI::check_entity_tables();
|
||||||
|
|
||||||
|
$this->assertFalse( $ok );
|
||||||
|
$this->assertStringContainsString( '[MISSING] wp_itest_wpdo_user_dt_missing', $this->cli_output() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reports_drifting_groups(): void {
|
||||||
|
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
|
||||||
|
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ] = array(
|
||||||
|
'user' => array(
|
||||||
|
'dt_have' => array(
|
||||||
|
'miss' => 1,
|
||||||
|
'diff' => 2,
|
||||||
|
'extra' => 0,
|
||||||
|
'sample' => 0,
|
||||||
|
'checked_at' => '2026-09-27 00:00:00',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
|
||||||
|
TMDO_CLI::report_guard_and_drift();
|
||||||
|
|
||||||
|
$this->assertStringContainsString( 'dt_have miss=1 diff=2 extra=0', $this->cli_output() );
|
||||||
|
$this->assertStringContainsString( 'wp tmdo drift-check user --fix', $this->cli_output() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ignores_the_record_of_a_group_no_longer_registered(): void {
|
||||||
|
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
|
||||||
|
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ] = array(
|
||||||
|
'user' => array(
|
||||||
|
'dt_have' => array(
|
||||||
|
'miss' => 0,
|
||||||
|
'diff' => 0,
|
||||||
|
'extra' => 0,
|
||||||
|
'sample' => 0,
|
||||||
|
'checked_at' => '2026-09-27 00:00:00',
|
||||||
|
),
|
||||||
|
'dt_gone' => array(
|
||||||
|
'miss' => 3,
|
||||||
|
'diff' => 0,
|
||||||
|
'extra' => 0,
|
||||||
|
'sample' => 0,
|
||||||
|
'checked_at' => '2026-09-20 00:00:00',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
|
||||||
|
TMDO_CLI::report_guard_and_drift();
|
||||||
|
|
||||||
|
$this->assertStringContainsString( '[OK] user: 1 group(s) clean (oldest check 2026-09-27 00:00:00 UTC)', $this->cli_output() );
|
||||||
|
$this->assertStringNotContainsString( 'dt_gone', $this->cli_output() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reports_an_entity_never_checked(): void {
|
||||||
|
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
|
||||||
|
TMDO_CLI::report_guard_and_drift();
|
||||||
|
|
||||||
|
$this->assertStringContainsString( 'user: never checked', $this->cli_output() );
|
||||||
|
$this->assertStringContainsString( 'Promotion Guard: no violation', $this->cli_output() );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TMDO_Drift_Check (2.2 PR-070): read-only flat-vs-native comparison.
|
||||||
|
*
|
||||||
|
* Fixture (user entity, group dc_grp):
|
||||||
|
* user 1 every key equal in value, differently formatted
|
||||||
|
* (50 vs 50.000000, serialized array vs JSON, required flag holding
|
||||||
|
* its default with no native row) → clean
|
||||||
|
* user 2 name 'b' vs 'B', cnt 5 vs 6 → diff ×2
|
||||||
|
* user 3 native name, flat row with name NULL → miss
|
||||||
|
* user 4 no native rows, flat cnt 9 → extra
|
||||||
|
* user 5 native name '', flat NULL → clean
|
||||||
|
* user 6 native name longer than a text column, flat NULL → clean
|
||||||
|
* user 7 native cnt, no flat row → miss
|
||||||
|
*/
|
||||||
|
class DriftCheckTest extends TestCase {
|
||||||
|
|
||||||
|
private const TABLE = 'wp_itest_wpdo_user_dc_grp';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-user.php',
|
||||||
|
'includes/diagnostic/class-tmdo-drift-check.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once WPDO_PLUGIN_DIR . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->users}` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_login VARCHAR(60) NOT NULL DEFAULT '', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, num DECIMAL(18,6) DEFAULT NULL, cnt BIGINT(20) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, prefs TEXT DEFAULT NULL, meta_json LONGTEXT DEFAULT NULL, flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||||
|
|
||||||
|
for ( $id = 1; $id <= 7; $id++ ) {
|
||||||
|
$wpdb->insert( $wpdb->users, array( 'ID' => $id, 'user_login' => "u{$id}" ) );
|
||||||
|
}
|
||||||
|
$prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) );
|
||||||
|
$this->native( 1, array( 'num' => '50', 'cnt' => '3', 'name' => 'a', 'prefs' => $prefs, 'meta_json' => serialize( array( 'w' => 640, 'tags' => array( 'x' ) ) ) ) );
|
||||||
|
$this->flat( array( 'user_id' => 1, 'num' => '50.000000', 'cnt' => 3, 'name' => 'a', 'prefs' => $prefs, 'meta_json' => '{"w":640,"tags":["x"]}' ) );
|
||||||
|
$this->native( 2, array( 'name' => 'b', 'cnt' => '5' ) );
|
||||||
|
$this->flat( array( 'user_id' => 2, 'name' => 'B', 'cnt' => 6 ) );
|
||||||
|
$this->native( 3, array( 'name' => 'c' ) );
|
||||||
|
$this->flat( array( 'user_id' => 3, 'cnt' => null ) );
|
||||||
|
$this->flat( array( 'user_id' => 4, 'cnt' => 9 ) );
|
||||||
|
$this->native( 5, array( 'name' => '' ) );
|
||||||
|
$this->flat( array( 'user_id' => 5 ) );
|
||||||
|
$this->native( 6, array( 'name' => str_repeat( 'x', 300 ) ) );
|
||||||
|
$this->flat( array( 'user_id' => 6 ) );
|
||||||
|
$this->native( 7, array( 'cnt' => '7' ) );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||||
|
WPDO_Entity_Registry::register_group(
|
||||||
|
'user',
|
||||||
|
'dc_grp',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'num', 'type' => 'decimal' ),
|
||||||
|
array( 'key' => 'cnt', 'type' => 'integer' ),
|
||||||
|
array( 'key' => 'name', 'type' => 'text' ),
|
||||||
|
array( 'key' => 'prefs', 'type' => 'textarea' ),
|
||||||
|
array( 'key' => 'meta_json', 'type' => 'json' ),
|
||||||
|
array( 'key' => 'flag', 'type' => 'integer', 'default' => 0, 'required' => true ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
WPDO_Schema_Manager::flush_table_exists_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( $wpdb->users, $wpdb->usermeta, self::TABLE ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function native( int $user_id, array $values ): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( $values as $key => $value ) {
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flat( array $row ): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->insert( self::TABLE, $row );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snapshot(): string {
|
||||||
|
global $wpdb;
|
||||||
|
return md5( wp_json_encode( array( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ), $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_counts_miss_diff_and_extra_per_key(): void {
|
||||||
|
$report = TMDO_Drift_Check::run( 'user' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] );
|
||||||
|
$keys = $report['groups']['dc_grp']['keys'];
|
||||||
|
$this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 0 ), $keys['name'] );
|
||||||
|
$this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 1 ), $keys['cnt'] );
|
||||||
|
foreach ( array( 'num', 'prefs', 'meta_json', 'flag' ) as $key ) {
|
||||||
|
$this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $keys[ $key ], "{$key}: equal by value" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_records_the_outcome_per_group(): void {
|
||||||
|
TMDO_Drift_Check::run( 'user', 'dc_grp' );
|
||||||
|
|
||||||
|
$status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp'];
|
||||||
|
$this->assertSame( 2, $status['miss'] );
|
||||||
|
$this->assertSame( 2, $status['diff'] );
|
||||||
|
$this->assertSame( 1, $status['extra'] );
|
||||||
|
$this->assertSame( 0, $status['sample'] );
|
||||||
|
$this->assertNotEmpty( $status['checked_at'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_sample_covering_every_user_matches_the_full_check(): void {
|
||||||
|
$report = TMDO_Drift_Check::run( 'user', '', 100 );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] );
|
||||||
|
$this->assertSame( 100, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['sample'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_never_writes_native_or_flat(): void {
|
||||||
|
$before = $this->snapshot();
|
||||||
|
|
||||||
|
TMDO_Drift_Check::run( 'user' );
|
||||||
|
|
||||||
|
$this->assertSame( $before, $this->snapshot() );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fix() (2.2 PR-071) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function flat_row( int $user_id ): ?array {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->get_row( $wpdb->prepare( 'SELECT num, cnt, name, flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_fix_rebuilds_drifted_rows_from_native_and_leaves_no_drift(): void {
|
||||||
|
$result = TMDO_Drift_Check::fix( 'user' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $result['after']['total'] );
|
||||||
|
$this->assertSame( 'b', $this->flat_row( 2 )['name'] );
|
||||||
|
$this->assertSame( '5', $this->flat_row( 2 )['cnt'] );
|
||||||
|
$this->assertSame( 'c', $this->flat_row( 3 )['name'] );
|
||||||
|
$this->assertSame( array( 'num' => null, 'cnt' => '7', 'name' => null, 'flag' => '0' ), $this->flat_row( 7 ), 'new row: no invented defaults' );
|
||||||
|
$this->assertSame( array( 'num' => null, 'cnt' => null, 'name' => null, 'flag' => '0' ), $this->flat_row( 4 ), 'no native values: row kept, columns cleared' );
|
||||||
|
$this->assertSame( 0, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['diff'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_fix_deletes_rows_of_entities_that_no_longer_exist(): void {
|
||||||
|
$this->flat( array( 'user_id' => 99, 'name' => 'ghost' ) );
|
||||||
|
|
||||||
|
$result = TMDO_Drift_Check::fix( 'user' );
|
||||||
|
|
||||||
|
$this->assertSame( 1, $result['fixed']['deleted_rows'] );
|
||||||
|
$this->assertNull( $this->flat_row( 99 ) );
|
||||||
|
$this->assertNotNull( $this->flat_row( 1 ), 'rows of existing users stay' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_fix_never_writes_native_and_invalidates_the_row_cache(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$native = md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) );
|
||||||
|
TMDO_Cache_Orchestrator::set_row( 'user', 2, 'dc_grp', array( 'name' => 'B' ) );
|
||||||
|
|
||||||
|
TMDO_Drift_Check::fix( 'user' );
|
||||||
|
|
||||||
|
$this->assertSame( $native, md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) ) );
|
||||||
|
$this->assertFalse( TMDO_Cache_Orchestrator::get_row( 'user', 2, 'dc_grp' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cron_tick() (2.2 PR-073) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function set_modes( ?array $modes ): void {
|
||||||
|
$cache = ( new ReflectionClass( WPDO_Mode_Manager::class ) )->getProperty( 'cache' );
|
||||||
|
$cache->setAccessible( true );
|
||||||
|
$cache->setValue( null, $modes );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_daily_tick_samples_entities_that_write_to_flat(): void {
|
||||||
|
self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
try {
|
||||||
|
TMDO_Drift_Check::cron_tick();
|
||||||
|
} finally {
|
||||||
|
self::set_modes( null );
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = get_option( TMDO_Drift_Check::OPTION );
|
||||||
|
$this->assertSame( TMDO_Drift_Check::CRON_SAMPLE, $status['user']['dc_grp']['sample'] );
|
||||||
|
$this->assertSame( 2, $status['user']['dc_grp']['diff'], 'the sample covers all 7 fixture users' );
|
||||||
|
$this->assertArrayNotHasKey( 'post', $status );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_daily_tick_skips_entities_that_do_not_write_to_flat(): void {
|
||||||
|
self::set_modes( array( 'post' => 'disabled', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
try {
|
||||||
|
TMDO_Drift_Check::cron_tick();
|
||||||
|
} finally {
|
||||||
|
self::set_modes( null );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_daily_tick_skips_aeav_only_where_flat_is_the_source_of_truth(): void {
|
||||||
|
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
try {
|
||||||
|
TMDO_Drift_Check::cron_tick();
|
||||||
|
} finally {
|
||||||
|
self::set_modes( null );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_fix_refuses_aeav_only_and_leaves_flat_alone(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$flat = md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) );
|
||||||
|
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
try {
|
||||||
|
TMDO_Drift_Check::fix( 'user' );
|
||||||
|
$this->fail( 'fix() must refuse aeav_only' );
|
||||||
|
} catch ( RuntimeException $e ) {
|
||||||
|
$this->assertStringContainsString( 'aeav_only', $e->getMessage() );
|
||||||
|
} finally {
|
||||||
|
self::set_modes( null );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame( $flat, md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── resync after writes bypassed flat (2.2 PR-074) ──────────────────────
|
||||||
|
|
||||||
|
public function test_turning_writes_on_schedules_a_fix_for_that_entity(): void {
|
||||||
|
$this->assertSame( array( 'user' ), TMDO_Drift_Check::resync_after_mode_change( 'user', 'dual_write', 'disabled' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_other_mode_changes_schedule_nothing(): void {
|
||||||
|
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'shadow_read', 'dual_write' ) );
|
||||||
|
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'aeav_only', 'shadow_read' ) );
|
||||||
|
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'disabled', 'dual_write' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_turning_the_hook_bus_on_schedules_a_fix_for_entities_that_keep_native(): void {
|
||||||
|
self::set_modes( array( 'post' => 'aeav_only', 'user' => 'dual_write', 'term' => 'shadow_read', 'comment' => 'disabled' ) );
|
||||||
|
try {
|
||||||
|
$on = TMDO_Drift_Check::resync_after_hook_bus_change( '0', '1' );
|
||||||
|
$off = TMDO_Drift_Check::resync_after_hook_bus_change( '1', '0' );
|
||||||
|
} finally {
|
||||||
|
self::set_modes( null );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame( array( 'user', 'term' ), $on );
|
||||||
|
$this->assertSame( array(), $off );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_scheduled_fix_repairs_the_entity(): void {
|
||||||
|
self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
try {
|
||||||
|
TMDO_Drift_Check::run_scheduled_fix( 'user' );
|
||||||
|
} finally {
|
||||||
|
self::set_modes( null );
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp'];
|
||||||
|
$this->assertSame( 0, $status['miss'] + $status['diff'] + $status['extra'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_scheduled_fix_leaves_aeav_only_alone(): void {
|
||||||
|
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
try {
|
||||||
|
TMDO_Drift_Check::run_scheduled_fix( 'user' );
|
||||||
|
} finally {
|
||||||
|
self::set_modes( null );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
|
||||||
|
$this->assertSame( 'B', $this->flat_row( 2 )['name'], 'flat untouched' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_resync_triggers_are_hooked(): void {
|
||||||
|
$source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' );
|
||||||
|
|
||||||
|
foreach ( array(
|
||||||
|
"add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );",
|
||||||
|
"add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );",
|
||||||
|
"add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );",
|
||||||
|
"add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );",
|
||||||
|
) as $line ) {
|
||||||
|
$this->assertStringContainsString( $line, $source );
|
||||||
|
}
|
||||||
|
// Literal: uninstall.php does not load TMDO_Drift_Check.
|
||||||
|
$installer = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php' );
|
||||||
|
$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' );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TMDO_Entity_Migration_Engine::migrate_group() must not invent values for keys
|
||||||
|
* the object has no native row for.
|
||||||
|
*
|
||||||
|
* Bug (dev36 R-2.1, 2026-09-26): the engine REPLACEs only the columns it found
|
||||||
|
* native rows for, so every other column fell back to its DDL default —
|
||||||
|
* user activity.session_count / account_flags (nullable, DEFAULT 0) became 0
|
||||||
|
* for every backfilled user, and the checker's "extra" count went 60 → 123.
|
||||||
|
*
|
||||||
|
* Fixture (user entity, group md_act):
|
||||||
|
* login_count integer, default 0, nullable
|
||||||
|
* session_count integer, default 0, nullable
|
||||||
|
* note text
|
||||||
|
* req_flag integer, required (NOT NULL DEFAULT 0)
|
||||||
|
*/
|
||||||
|
class EntityMigrationEngineDefaultsTest extends TestCase {
|
||||||
|
|
||||||
|
private const TABLE = 'wp_itest_wpdo_user_md_act';
|
||||||
|
private const STATUS = 'wp_itest_wpdo_migration_status';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-type-caster.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-entity-migration-engine.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-user.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once WPDO_PLUGIN_DIR . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, login_count BIGINT(20) DEFAULT 0, session_count BIGINT(20) DEFAULT 0, note VARCHAR(255) DEFAULT NULL, req_flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||||
|
WPDO_Entity_Registry::register_group(
|
||||||
|
'user',
|
||||||
|
'md_act',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'login_count', 'type' => 'integer', 'default' => 0 ),
|
||||||
|
array( 'key' => 'session_count', 'type' => 'integer', 'default' => 0 ),
|
||||||
|
array( 'key' => 'note', 'type' => 'text' ),
|
||||||
|
array( 'key' => 'req_flag', 'type' => 'integer', 'default' => 0, 'required' => true ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
WPDO_Schema_Manager::flush_table_exists_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( $wpdb->usermeta, self::TABLE, self::STATUS ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function native( int $user_id, string $key, string $value ): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string|null> */
|
||||||
|
private function flat_row( int $user_id ): array {
|
||||||
|
global $wpdb;
|
||||||
|
return (array) $wpdb->get_row( $wpdb->prepare( 'SELECT login_count, session_count, note, req_flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function migrate(): array {
|
||||||
|
return TMDO_Entity_Migration_Engine::migrate_group( 'user', 'md_act', array( 'resume' => false, 'sleep_ms' => 0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_keys_without_a_native_row_stay_null_instead_of_the_column_default(): void {
|
||||||
|
$this->native( 1, 'login_count', '7' );
|
||||||
|
|
||||||
|
$report = $this->migrate();
|
||||||
|
|
||||||
|
$this->assertSame( 1, $report['migrated'] );
|
||||||
|
$row = $this->flat_row( 1 );
|
||||||
|
$this->assertSame( '7', $row['login_count'] );
|
||||||
|
$this->assertNull( $row['session_count'], 'no native session_count — must not become the DEFAULT 0' );
|
||||||
|
$this->assertNull( $row['note'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_native_value_equal_to_the_default_is_still_written(): void {
|
||||||
|
$this->native( 1, 'session_count', '0' );
|
||||||
|
|
||||||
|
$this->migrate();
|
||||||
|
|
||||||
|
$this->assertSame( '0', $this->flat_row( 1 )['session_count'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_stale_flat_value_is_cleared_when_the_native_row_is_gone(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->insert( self::TABLE, array( 'user_id' => 1, 'login_count' => 3, 'session_count' => 5, 'note' => 'old' ) );
|
||||||
|
$this->native( 1, 'login_count', '4' );
|
||||||
|
|
||||||
|
$this->migrate();
|
||||||
|
|
||||||
|
$row = $this->flat_row( 1 );
|
||||||
|
$this->assertSame( '4', $row['login_count'] );
|
||||||
|
$this->assertNull( $row['session_count'] );
|
||||||
|
$this->assertNull( $row['note'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_required_column_without_a_native_row_keeps_its_default(): void {
|
||||||
|
$this->native( 1, 'login_count', '1' );
|
||||||
|
|
||||||
|
$report = $this->migrate();
|
||||||
|
|
||||||
|
$this->assertSame( 0, $report['errors'] );
|
||||||
|
$this->assertSame( '0', $this->flat_row( 1 )['req_flag'], 'NOT NULL column cannot hold NULL — its default is the documented stand-in' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_cron_batch_path_does_not_invent_defaults_either(): void {
|
||||||
|
$this->native( 1, 'login_count', '2' );
|
||||||
|
|
||||||
|
$result = TMDO_Entity_Migration_Engine::migrate_group_batch( 'user', 'md_act' );
|
||||||
|
|
||||||
|
$this->assertSame( 1, $result['migrated'] );
|
||||||
|
$this->assertNull( $this->flat_row( 1 )['session_count'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,6 +215,38 @@ class PostBackfillJsonTest extends TestCase {
|
|||||||
$this->assertSame( 0, $result['errors'] ?? -1 );
|
$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 {
|
public function test_returns_error_for_unknown_group(): void {
|
||||||
$result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' );
|
$result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' );
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ class PostEntityLifecycleTest extends TestCase {
|
|||||||
WPDO_Entity_Registry::init();
|
WPDO_Entity_Registry::init();
|
||||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
WPDO_Post_Fields::register_entity_fields();
|
WPDO_Post_Fields::register_entity_fields();
|
||||||
|
tmdo_test_register_wc_product_group();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function tearDownAfterClass(): void {
|
public static function tearDownAfterClass(): void {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class PostMigrationTest extends TestCase {
|
|||||||
WPDO_Entity_Registry::init();
|
WPDO_Entity_Registry::init();
|
||||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
WPDO_Post_Fields::register_entity_fields();
|
WPDO_Post_Fields::register_entity_fields();
|
||||||
|
tmdo_test_register_wc_product_group();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function tearDownAfterClass(): void {
|
public static function tearDownAfterClass(): void {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The post query router leaves a query native while the last drift-check
|
||||||
|
* found a routed clause's group out of step with native meta (2.2 PR-072).
|
||||||
|
* All or nothing: one drifting group keeps the whole query native. A group
|
||||||
|
* never checked keeps routing (decision 2026-09-27).
|
||||||
|
*/
|
||||||
|
class PostQueryRouterDriftTest extends TestCase {
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Post_Fields::register_entity_fields();
|
||||||
|
tmdo_test_register_wc_product_group();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tearDownAfterClass(): void {
|
||||||
|
self::set_modes( null );
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
self::set_modes(
|
||||||
|
array(
|
||||||
|
'post' => 'shadow_read',
|
||||||
|
'user' => 'disabled',
|
||||||
|
'term' => 'disabled',
|
||||||
|
'comment' => 'disabled',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function set_modes( ?array $modes ): void {
|
||||||
|
$cache = ( new ReflectionClass( WPDO_Mode_Manager::class ) )->getProperty( 'cache' );
|
||||||
|
$cache->setAccessible( true );
|
||||||
|
$cache->setValue( null, $modes );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function drift( string $group, int $diff ): void {
|
||||||
|
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ]['post'][ $group ] = array(
|
||||||
|
'miss' => 0,
|
||||||
|
'diff' => $diff,
|
||||||
|
'extra' => 0,
|
||||||
|
'sample' => 0,
|
||||||
|
'checked_at' => '2026-09-27 00:00:00',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function price_query( array $extra = array() ): WP_Query {
|
||||||
|
$query = new WP_Query();
|
||||||
|
foreach ( array(
|
||||||
|
'post_type' => 'product',
|
||||||
|
'meta_query' => array(
|
||||||
|
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
|
||||||
|
),
|
||||||
|
) + $extra as $k => $v ) {
|
||||||
|
$query->set( $k, $v );
|
||||||
|
}
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_drifting_group_keeps_the_query_native(): void {
|
||||||
|
$this->drift( 'wc_product', 3 );
|
||||||
|
$query = $this->price_query();
|
||||||
|
$original = $query->get( 'meta_query' );
|
||||||
|
|
||||||
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertSame( $original, $query->get( 'meta_query' ) );
|
||||||
|
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_clean_group_is_routed(): void {
|
||||||
|
$this->drift( 'wc_product', 0 );
|
||||||
|
$query = $this->price_query();
|
||||||
|
|
||||||
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_group_never_checked_is_routed(): void {
|
||||||
|
$query = $this->price_query();
|
||||||
|
|
||||||
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_drift_in_another_group_does_not_matter(): void {
|
||||||
|
$this->drift( 'hp_listing_core', 5 );
|
||||||
|
$query = $this->price_query();
|
||||||
|
|
||||||
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aeav_only_ignores_drift_because_native_is_stale_by_design(): void {
|
||||||
|
self::set_modes(
|
||||||
|
array(
|
||||||
|
'post' => 'aeav_only',
|
||||||
|
'user' => 'disabled',
|
||||||
|
'term' => 'disabled',
|
||||||
|
'comment' => 'disabled',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$this->drift( 'wc_product', 3 );
|
||||||
|
$query = $this->price_query();
|
||||||
|
|
||||||
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sorting_by_a_drifting_key_stays_native(): void {
|
||||||
|
$this->drift( 'wc_product', 1 );
|
||||||
|
$query = new WP_Query();
|
||||||
|
$query->set( 'post_type', 'product' );
|
||||||
|
$query->set( 'meta_key', '_price' );
|
||||||
|
$query->set( 'orderby', 'meta_value_num' );
|
||||||
|
|
||||||
|
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$this->assertSame( '_price', $query->get( 'meta_key' ) );
|
||||||
|
$this->assertSame( '', $query->get( 'wpdo_post_sort' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ class PostQueryRouterOrderTest extends TestCase {
|
|||||||
WPDO_Entity_Registry::init();
|
WPDO_Entity_Registry::init();
|
||||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
WPDO_Post_Fields::register_entity_fields();
|
WPDO_Post_Fields::register_entity_fields();
|
||||||
|
tmdo_test_register_wc_product_group();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function setUp(): void {
|
protected function setUp(): void {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class PostQueryRouterTest extends TestCase {
|
|||||||
WPDO_Entity_Registry::init();
|
WPDO_Entity_Registry::init();
|
||||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
WPDO_Post_Fields::register_entity_fields();
|
WPDO_Post_Fields::register_entity_fields();
|
||||||
|
tmdo_test_register_wc_product_group();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function tearDownAfterClass(): void {
|
public static function tearDownAfterClass(): void {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TMDO_Shadow_Diff_Logger throttling (2.2 PR-075).
|
||||||
|
*
|
||||||
|
* The per-object window (one row per entity + key per 5 minutes) did not stop
|
||||||
|
* a key that is stale on every object — WooCommerce's total_sales, written by
|
||||||
|
* direct SQL — from logging a row for each product read. A key now records at
|
||||||
|
* most KEY_LIMIT rows per KEY_WINDOW across all objects.
|
||||||
|
*/
|
||||||
|
class ShadowDiffThrottleTest extends TestCase {
|
||||||
|
|
||||||
|
private const DIFFS = 'wp_itest_wpdo_shadow_diffs';
|
||||||
|
private const ERRORS = 'wp_itest_wpdo_errors';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-user.php',
|
||||||
|
'includes/engine/class-tmdo-shadow-diff-logger.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once WPDO_PLUGIN_DIR . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::DIFFS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ts DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', entity_type VARCHAR(20) NOT NULL DEFAULT '', entity_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) NOT NULL DEFAULT '', postmeta_value LONGTEXT, zone_value LONGTEXT, diff_hash VARCHAR(40) NOT NULL DEFAULT '', PRIMARY KEY (id) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::ERRORS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, module VARCHAR(100) NOT NULL DEFAULT '', zone VARCHAR(20) NOT NULL DEFAULT '', hook VARCHAR(255) NOT NULL DEFAULT '', message TEXT NOT NULL, context LONGTEXT, created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id) ) ENGINE=InnoDB" );
|
||||||
|
for ( $id = 1; $id <= 30; $id++ ) {
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'total_sales', 'meta_value' => '5' ) );
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'other_key', 'meta_value' => '5' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$GLOBALS['_wp_transients'] = array();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( $wpdb->usermeta, self::DIFFS, self::ERRORS ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logged( string $key ): int {
|
||||||
|
global $wpdb;
|
||||||
|
return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM `' . self::DIFFS . '` WHERE meta_key = %s', $key ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stale_read( int $user_id, string $key ): void {
|
||||||
|
TMDO_Shadow_Diff_Logger::compare_and_log( 'user', $user_id, $key, 4, array( 'type' => 'integer' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_key_stale_on_every_object_logs_at_most_the_key_limit(): void {
|
||||||
|
for ( $id = 1; $id <= 30; $id++ ) {
|
||||||
|
$this->stale_read( $id, 'total_sales' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame( TMDO_Shadow_Diff_Logger::KEY_LIMIT, $this->logged( 'total_sales' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_limit_is_per_key(): void {
|
||||||
|
for ( $id = 1; $id <= 30; $id++ ) {
|
||||||
|
$this->stale_read( $id, 'total_sales' );
|
||||||
|
}
|
||||||
|
$this->stale_read( 1, 'other_key' );
|
||||||
|
|
||||||
|
$this->assertSame( 1, $this->logged( 'other_key' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_same_object_and_key_still_log_once_per_window(): void {
|
||||||
|
$this->stale_read( 1, 'total_sales' );
|
||||||
|
$this->stale_read( 1, 'total_sales' );
|
||||||
|
|
||||||
|
$this->assertSame( 1, $this->logged( 'total_sales' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -517,10 +517,12 @@ if ( ! function_exists( 'wp_insert_comment' ) ) {
|
|||||||
|
|
||||||
if ( ! class_exists( 'WP_CLI' ) ) {
|
if ( ! class_exists( 'WP_CLI' ) ) {
|
||||||
class WP_CLI {
|
class WP_CLI {
|
||||||
public static function log( string $msg ): void {}
|
// Output is kept in $GLOBALS['_wp_cli_output'] so CLI tests can assert it.
|
||||||
public static function warning( string $msg ): void {}
|
public static function log( string $msg ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
|
||||||
public static function success( string $msg ): void {}
|
public static function line( string $msg = '' ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
|
||||||
public static function error( string $msg ): void {}
|
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 {}
|
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-schema-manager.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.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/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-entity-health.php';
|
||||||
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.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-hook-bus.php';
|
||||||
@@ -646,3 +649,43 @@ if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
|
|||||||
}
|
}
|
||||||
class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' );
|
class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WooCommerce product group core registered before 1.2.0 (PR-077 moved
|
||||||
|
* product meta back to native only). Tests that use products as a routed
|
||||||
|
* post type register it themselves; the table name wp_itest_wpdo_post_wc_product
|
||||||
|
* is unchanged.
|
||||||
|
*/
|
||||||
|
function tmdo_test_register_wc_product_group(): void {
|
||||||
|
$enum = static fn( string $key, array $options ): array => array(
|
||||||
|
'key' => $key,
|
||||||
|
'type' => 'enum',
|
||||||
|
'options' => $options,
|
||||||
|
);
|
||||||
|
TMDO_Entity_Registry::register_group(
|
||||||
|
'post',
|
||||||
|
'wc_product',
|
||||||
|
array(
|
||||||
|
array( 'key' => '_price', 'type' => 'decimal', 'searchable' => true ),
|
||||||
|
array( 'key' => '_regular_price', 'type' => 'decimal', 'searchable' => true ),
|
||||||
|
array( 'key' => '_sale_price', 'type' => 'decimal', 'searchable' => true ),
|
||||||
|
array( 'key' => '_stock', 'type' => 'integer', 'searchable' => true ),
|
||||||
|
array( 'key' => '_stock_status', 'type' => 'enum', 'searchable' => true, 'options' => array( 'instock', 'outofstock', 'onbackorder' ) ),
|
||||||
|
array( 'key' => '_sku', 'type' => 'text', 'searchable' => true ),
|
||||||
|
$enum( '_manage_stock', array( 'yes', 'no' ) ),
|
||||||
|
$enum( '_backorders', array( 'yes', 'no', 'notify' ) ),
|
||||||
|
$enum( '_sold_individually', array( 'yes', 'no' ) ),
|
||||||
|
$enum( '_virtual', array( 'yes', 'no' ) ),
|
||||||
|
$enum( '_downloadable', array( 'yes', 'no' ) ),
|
||||||
|
array( 'key' => '_tax_class', 'type' => 'text' ),
|
||||||
|
$enum( '_tax_status', array( 'taxable', 'shipping', 'none' ) ),
|
||||||
|
array( 'key' => '_download_limit', 'type' => 'integer' ),
|
||||||
|
array( 'key' => '_download_expiry', 'type' => 'integer' ),
|
||||||
|
array( 'key' => '_product_version', 'type' => 'text' ),
|
||||||
|
array( 'key' => '_wc_average_rating', 'type' => 'decimal', 'searchable' => true ),
|
||||||
|
array( 'key' => '_wc_review_count', 'type' => 'integer' ),
|
||||||
|
array( 'key' => 'total_sales', 'type' => 'integer', 'searchable' => true ),
|
||||||
|
),
|
||||||
|
array( 'post_types' => array( 'product' ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test:
|
|||||||
| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
|
||||||
|
|
||||||
## Tools (`tools/`)
|
## Tools (`tools/`)
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Suite: TMDO_Drift_Check on a real site (2.2 PR-070).
|
||||||
|
*
|
||||||
|
* - json groups (attachment / nav_menu_item) compare by value: a serialized
|
||||||
|
* native array and its JSON flat copy are not drift.
|
||||||
|
* - a flat value planted behind the checker's back shows up as exactly one
|
||||||
|
* diff / one extra, nothing else moves.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the drift check for one group and put the site's recorded status back.
|
||||||
|
*
|
||||||
|
* @param string $entity Entity type.
|
||||||
|
* @param string $group Group.
|
||||||
|
* @return array<string, mixed> Report.
|
||||||
|
*/
|
||||||
|
function tmdo_live_drift( string $entity, string $group ): array {
|
||||||
|
$status = get_option( TMDO_Drift_Check::OPTION, null );
|
||||||
|
try {
|
||||||
|
return TMDO_Drift_Check::run( $entity, $group );
|
||||||
|
} finally {
|
||||||
|
null === $status ? delete_option( TMDO_Drift_Check::OPTION ) : update_option( TMDO_Drift_Check::OPTION, $status );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'drift-check reports no diff for json groups whose values match native',
|
||||||
|
static function () {
|
||||||
|
$checked = 0;
|
||||||
|
foreach ( array( 'attachment', 'nav_menu_item' ) as $group ) {
|
||||||
|
if ( ! in_array( $group, TMDO_Entity_Migration_Engine::backfillable_groups( 'post' ), true )
|
||||||
|
|| ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( 'post', $group ) ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$report = tmdo_live_drift( 'post', $group );
|
||||||
|
tmdo_live_assert_same( 0, $report['total']['diff'], "post/{$group} diff" );
|
||||||
|
++$checked;
|
||||||
|
}
|
||||||
|
if ( 0 === $checked ) {
|
||||||
|
throw new TMDO_Live_Skip( 'no attachment / nav_menu_item flat table on this site' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'drift-check counts a planted flat mismatch once and a flat-only value once',
|
||||||
|
static function () {
|
||||||
|
global $wpdb;
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'user' => 'disabled' ) );
|
||||||
|
$field = TMDO_Live_Fixtures::field( 'user', 'login_count' );
|
||||||
|
$group = $field['group'];
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'user', $group );
|
||||||
|
|
||||||
|
$before = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count'];
|
||||||
|
|
||||||
|
$mismatch = TMDO_Live_Fixtures::user();
|
||||||
|
$flat_only = TMDO_Live_Fixtures::user();
|
||||||
|
add_user_meta( $mismatch, 'login_count', '5' );
|
||||||
|
try {
|
||||||
|
$wpdb->insert( $table, array( 'user_id' => $mismatch, 'login_count' => 6 ) );
|
||||||
|
$wpdb->insert( $table, array( 'user_id' => $flat_only, 'login_count' => 9 ) );
|
||||||
|
|
||||||
|
$after = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count'];
|
||||||
|
} finally {
|
||||||
|
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE user_id IN (%d, %d)", $mismatch, $flat_only ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
}
|
||||||
|
|
||||||
|
tmdo_live_assert_same( $before['diff'] + 1, $after['diff'], 'login_count diff +1' );
|
||||||
|
tmdo_live_assert_same( $before['extra'] + 1, $after['extra'], 'login_count extra +1' );
|
||||||
|
tmdo_live_assert_same( $before['miss'], $after['miss'], 'login_count miss unchanged' );
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -247,21 +247,22 @@ TMDO_Live::test(
|
|||||||
TMDO_Live::test(
|
TMDO_Live::test(
|
||||||
'Q06 LIKE matches substrings and escapes wildcards, routed',
|
'Q06 LIKE matches substrings and escapes wildcards, routed',
|
||||||
static function () {
|
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' ) );
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
$p = tmdo_live_router_posts(
|
$p = tmdo_live_router_posts(
|
||||||
'product',
|
'post',
|
||||||
array(
|
array(
|
||||||
'P1' => array( '_sku' => 'tmdo-live-B1-1' ),
|
'P1' => array( '_wp_page_template' => 'tmdo-live-B1-1' ),
|
||||||
'P2' => array( '_sku' => 'tmdo-live-B1-10' ),
|
'P2' => array( '_wp_page_template' => 'tmdo-live-B1-10' ),
|
||||||
'P3' => array( '_sku' => 'tmdo-live-B2-1' ),
|
'P3' => array( '_wp_page_template' => 'tmdo-live-B2-1' ),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
foreach ( array( 'B1-1', 'B1_1' ) as $needle ) {
|
foreach ( array( 'B1-1', 'B1_1' ) as $needle ) {
|
||||||
tmdo_live_router_assert_native(
|
tmdo_live_router_assert_native(
|
||||||
array(
|
array(
|
||||||
'post_type' => 'product',
|
'post_type' => 'post',
|
||||||
'meta_query' => array( array( 'key' => '_sku', 'value' => $needle, 'compare' => 'LIKE' ) ),
|
'meta_query' => array( array( 'key' => '_wp_page_template', 'value' => $needle, 'compare' => 'LIKE' ) ),
|
||||||
),
|
),
|
||||||
array_values( $p ),
|
array_values( $p ),
|
||||||
true
|
true
|
||||||
@@ -423,3 +424,160 @@ TMDO_Live::test(
|
|||||||
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request );
|
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request );
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HivePress-style sort-only search (rating__order), fields ids, first page.
|
||||||
|
* DECIMAL(10,1): what the HivePress Rating field (decimals 1) sends.
|
||||||
|
*
|
||||||
|
* @param string $order ASC | DESC.
|
||||||
|
* @return array<string, mixed> WP_Query args.
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_rating_sort( string $order ): array {
|
||||||
|
return array(
|
||||||
|
'post_type' => 'hp_listing',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => 10,
|
||||||
|
'fields' => 'ids',
|
||||||
|
'suppress_filters' => false,
|
||||||
|
'meta_query' => array(
|
||||||
|
array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
'rating__order' => array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'NOT EXISTS' ),
|
||||||
|
array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'EXISTS' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'orderby' => 'rating__order',
|
||||||
|
'order' => $order,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The native sort key of each post, in order (null without a rating). On the
|
||||||
|
* whole site many listings share a rating and native orders ties by chance,
|
||||||
|
* so a page is compared by its keys, not its ids.
|
||||||
|
*
|
||||||
|
* @param array<int|string> $ids Post ids.
|
||||||
|
* @return array<int, string|null>
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_rating_keys( array $ids ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$ids = array_map( 'intval', $ids );
|
||||||
|
if ( empty( $ids ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
"SELECT post_id, CAST(meta_value AS DECIMAL(10,1)) AS k FROM {$wpdb->postmeta} WHERE meta_key = 'hp_rating' AND post_id IN (" . implode( ',', $ids ) . ')',
|
||||||
|
OBJECT_K
|
||||||
|
);
|
||||||
|
return array_map( static fn( $id ) => isset( $rows[ $id ] ) ? (string) $rows[ $id ]->k : null, $ids );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native and routed runs of the same query.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $args Query args.
|
||||||
|
* @return array{0:WP_Query, 1:WP_Query}
|
||||||
|
*/
|
||||||
|
function tmdo_live_router_native_and_routed( array $args ): array {
|
||||||
|
$native = new WP_Query( $args );
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
$router->register_hooks();
|
||||||
|
try {
|
||||||
|
$routed = new WP_Query( $args );
|
||||||
|
} finally {
|
||||||
|
tmdo_live_router_unhook( $router );
|
||||||
|
}
|
||||||
|
return array( $native, $routed );
|
||||||
|
}
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'a sort-only search drives from the flat table when every post of the type has a flat row (2.2 PR-079)',
|
||||||
|
static function () {
|
||||||
|
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
|
TMDO_Post_Query_Router::forget_coverage();
|
||||||
|
foreach ( array( 'DESC', 'ASC' ) as $order ) {
|
||||||
|
list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( $order ) );
|
||||||
|
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), "{$order} sort keys" );
|
||||||
|
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, "{$order} found_posts" );
|
||||||
|
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), "{$order} flat-first: " . $routed->request );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'a sort-only search keeps the LEFT JOIN while a post of the type has no flat row (2.2 PR-079)',
|
||||||
|
static function () {
|
||||||
|
global $wpdb;
|
||||||
|
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
|
$bare = TMDO_Live_Fixtures::post( 'hp_listing' );
|
||||||
|
$wpdb->delete( TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' ), array( 'post_id' => $bare ) );
|
||||||
|
TMDO_Post_Query_Router::forget_coverage();
|
||||||
|
try {
|
||||||
|
list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( 'DESC' ) );
|
||||||
|
} finally {
|
||||||
|
TMDO_Post_Query_Router::forget_coverage();
|
||||||
|
}
|
||||||
|
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
|
||||||
|
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
|
||||||
|
tmdo_live_assert_true( false === strpos( $routed->request, 'STRAIGHT_JOIN' ), 'not flat-first: ' . $routed->request );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'a post created while the router is active gets its empty flat row (2.2 PR-079)',
|
||||||
|
static function () {
|
||||||
|
global $wpdb;
|
||||||
|
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
$router->register_hooks();
|
||||||
|
try {
|
||||||
|
$id = TMDO_Live_Fixtures::post( 'hp_listing' );
|
||||||
|
} finally {
|
||||||
|
tmdo_live_router_unhook( $router );
|
||||||
|
}
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' );
|
||||||
|
tmdo_live_assert_same( '1', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE post_id = %d", $id ) ), 'flat row created' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'a flat-first search drops the GROUP BY WordPress adds for a subquery-only tax query (2.2 PR-079)',
|
||||||
|
static function () {
|
||||||
|
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
|
TMDO_Post_Query_Router::forget_coverage();
|
||||||
|
// Like WooCommerce's product_visibility exclusion on front-end searches: NOT IN
|
||||||
|
// is a subquery, yet WordPress groups by posts.ID for any tax query.
|
||||||
|
$args = tmdo_live_router_rating_sort( 'DESC' ) + array(
|
||||||
|
'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => array( 999999999 ), 'operator' => 'NOT IN' ) ),
|
||||||
|
);
|
||||||
|
list( $native, $routed ) = tmdo_live_router_native_and_routed( $args );
|
||||||
|
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
|
||||||
|
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
|
||||||
|
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request );
|
||||||
|
tmdo_live_assert_true( false === stripos( $routed->request, 'GROUP BY' ), 'no GROUP BY: ' . $routed->request );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'a flat-first search keeps the GROUP BY when a tax query joins term relationships (2.2 PR-079)',
|
||||||
|
static function () {
|
||||||
|
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
|
TMDO_Post_Query_Router::forget_coverage();
|
||||||
|
$terms = get_terms( array( 'taxonomy' => 'hp_listing_category', 'number' => 2, 'hide_empty' => true, 'fields' => 'ids' ) );
|
||||||
|
if ( count( (array) $terms ) < 2 ) {
|
||||||
|
throw new TMDO_Live_Skip( 'needs two used hp_listing_category terms' );
|
||||||
|
}
|
||||||
|
$args = tmdo_live_router_rating_sort( 'DESC' ) + array(
|
||||||
|
'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => $terms, 'operator' => 'IN' ) ),
|
||||||
|
);
|
||||||
|
list( $native, $routed ) = tmdo_live_router_native_and_routed( $args );
|
||||||
|
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
|
||||||
|
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
|
||||||
|
tmdo_live_assert_true( false !== stripos( $routed->request, 'GROUP BY' ), 'GROUP BY kept: ' . $routed->request );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TMDO_Auto_Promoter promotes shadow_read → aeav_only only when the last drift
|
||||||
|
* check of every group of the entity found nothing, within the last two days
|
||||||
|
* (2.2 PR-075; decision 2026-09-27). No record means no promotion.
|
||||||
|
*/
|
||||||
|
class AutoPromoterDriftTest extends TestCase {
|
||||||
|
|
||||||
|
private const TEN_DAYS_AGO = 864000;
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
ini_set( 'error_log', '/dev/null' );
|
||||||
|
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'shadow_read' ) );
|
||||||
|
$GLOBALS['_wp_filter_callbacks'] = array();
|
||||||
|
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
|
||||||
|
TMDO_Entity_Registry::register_group( 'user', 'ap_one', array( array( 'key' => 'ap_a', 'type' => 'text' ) ) );
|
||||||
|
TMDO_Entity_Registry::register_group( 'user', 'ap_two', array( array( 'key' => 'ap_b', 'type' => 'text' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function drift( string $group, int $diff, int $age = 0 ): void {
|
||||||
|
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ]['user'][ $group ] = array(
|
||||||
|
'miss' => 0,
|
||||||
|
'diff' => $diff,
|
||||||
|
'extra' => 0,
|
||||||
|
'sample' => TMDO_Drift_Check::CRON_SAMPLE,
|
||||||
|
'checked_at' => gmdate( 'Y-m-d H:i:s', time() - $age ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function check(): string {
|
||||||
|
return TMDO_Auto_Promoter::check_entity( 'user', 7, array( 'user' => time() - self::TEN_DAYS_AGO ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_promotes_when_every_group_checked_clean_recently(): void {
|
||||||
|
$this->drift( 'ap_one', 0 );
|
||||||
|
$this->drift( 'ap_two', 0 );
|
||||||
|
|
||||||
|
$this->assertSame( 'promoted', $this->check() );
|
||||||
|
$this->assertSame( 'aeav_only', TMDO_Mode_Manager::get( 'user' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_drift_in_one_group_blocks_promotion(): void {
|
||||||
|
$this->drift( 'ap_one', 0 );
|
||||||
|
$this->drift( 'ap_two', 1 );
|
||||||
|
|
||||||
|
$this->assertSame( 'drift_not_clean', $this->check() );
|
||||||
|
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_group_never_checked_blocks_promotion(): void {
|
||||||
|
$this->drift( 'ap_one', 0 );
|
||||||
|
|
||||||
|
$this->assertSame( 'drift_not_clean', $this->check() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_check_older_than_two_days_blocks_promotion(): void {
|
||||||
|
$this->drift( 'ap_one', 0 );
|
||||||
|
$this->drift( 'ap_two', 0, 3 * DAY_IN_SECONDS );
|
||||||
|
|
||||||
|
$this->assertSame( 'drift_not_clean', $this->check() );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `wp tmdo bridge-mode <entity> <mode>` (2.2 PR-069): the CLI way to change an
|
||||||
|
* entity's bridge mode. It must go through TMDO_Mode_Manager::set(), so unsafe
|
||||||
|
* jumps and promotions refused by the Promotion Guard never reach the option.
|
||||||
|
* (The Guard matrix itself is covered by PromotionGuardTest.)
|
||||||
|
*/
|
||||||
|
class CliBridgeModeTest extends TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
ini_set( 'error_log', '/dev/null' );
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$GLOBALS['_wp_filter_callbacks'] = array();
|
||||||
|
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function modes( array $modes ): void {
|
||||||
|
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes;
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sets_the_mode_of_one_entity(): void {
|
||||||
|
$this->modes( array( 'user' => 'disabled', 'post' => 'dual_write' ) );
|
||||||
|
|
||||||
|
( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'dual_write' ), array() );
|
||||||
|
|
||||||
|
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) );
|
||||||
|
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'post' ), 'other entities untouched' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_unsafe_jump_is_refused(): void {
|
||||||
|
$this->modes( array( 'user' => 'disabled' ) );
|
||||||
|
|
||||||
|
( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'aeav_only' ), array() );
|
||||||
|
|
||||||
|
$this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'user' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @runInSeparateProcess
|
||||||
|
* @preserveGlobalState disabled
|
||||||
|
*/
|
||||||
|
public function test_a_promotion_refused_by_the_guard_leaves_the_mode_unchanged(): void {
|
||||||
|
eval( 'namespace HivePress; class Core {}' );
|
||||||
|
$this->modes( array( 'user' => 'shadow_read' ) );
|
||||||
|
|
||||||
|
( new TMDO_CLI_V2() )->bridge_mode( array( 'user', 'aeav_only' ), array() );
|
||||||
|
|
||||||
|
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) );
|
||||||
|
$this->assertSame( array( 'user' => 'shadow_read' ), $GLOBALS['_wp_options']['wpdo_bridge_modes'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `wp tmdo term-comment-diagnose` filter chain status (2.2 PR-076).
|
||||||
|
*
|
||||||
|
* It read each filter's option with a default of '1', while the garbage filter
|
||||||
|
* and misc bucket default to off: a site that never touched them was told they
|
||||||
|
* were enabled. The status now comes from each filter's own is_enabled().
|
||||||
|
*/
|
||||||
|
class CliFilterStatusTest extends TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$GLOBALS['_wp_cli_output'] = array();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filter_line( string $class, string $label ): string {
|
||||||
|
$method = new ReflectionMethod( TMDO_CLI_Term_Comment::class, 'print_filter_status' );
|
||||||
|
$method->setAccessible( true );
|
||||||
|
$method->invoke( null, $class, $label );
|
||||||
|
return (string) end( $GLOBALS['_wp_cli_output'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_filters_off_by_default_show_disabled(): void {
|
||||||
|
$this->assertStringContainsString( 'disabled', $this->filter_line( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter' ) );
|
||||||
|
$this->assertStringContainsString( 'disabled', $this->filter_line( 'TMDO_Term_Comment_Misc_Bucket', 'Misc bucket' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_enabled_filter_shows_enabled(): void {
|
||||||
|
$GLOBALS['_wp_options'][ TMDO_Term_Comment_Garbage_Filter::OPT_ENABLED ] = '1';
|
||||||
|
|
||||||
|
$this->assertStringContainsString( '✓ enabled', $this->filter_line( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_filter_whose_add_on_is_not_active_says_so(): void {
|
||||||
|
$this->assertStringContainsString( 'not installed', $this->filter_line( 'TMDO_No_Such_Filter', 'WC term count filter' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,13 +33,12 @@ class PostFieldsRegistrationTest extends TestCase {
|
|||||||
$this->assertTrue( true );
|
$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();
|
WPDO_Post_Fields::register_entity_fields();
|
||||||
|
|
||||||
$expected = array(
|
$expected = array(
|
||||||
'wp_core',
|
'wp_core',
|
||||||
'attachment',
|
'attachment',
|
||||||
'wc_product',
|
|
||||||
'nav_menu_item',
|
'nav_menu_item',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -92,38 +91,14 @@ class PostFieldsRegistrationTest extends TestCase {
|
|||||||
$this->assertSame( 'json', $field['type'] );
|
$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();
|
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 {
|
$this->assertSame( array(), WPDO_Entity_Registry::get_group_fields( 'post', 'wc_product' ), 'WooCommerce reads and writes product meta directly (stock by SQL)' );
|
||||||
WPDO_Post_Fields::register_entity_fields();
|
$this->assertNull( WPDO_Entity_Registry::get_field( 'post', '_price', 'product' ) );
|
||||||
$keys = $this->get_field_keys( 'wc_product' );
|
$this->assertSame( array(), WPDO_Post_Fields::groups_for_post_type( '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'] );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HivePress groups moved to the HivePress AddOn ───────────────────────
|
// ── 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 {
|
public function test_core_groups_map_to_their_post_types(): void {
|
||||||
$this->assertSame( 'attachment', WPDO_Post_Fields::post_type_for_group( 'attachment' ) );
|
$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->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' );
|
$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( '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( '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' );
|
$this->assertNull( WPDO_Post_Fields::post_type_for_group( 'hp_listing_core' ), 'unregistered HivePress group is unknown to core' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user