Compare commits
10 Commits
d86c0a7bac
...
08492dd795
| Author | SHA1 | Date | |
|---|---|---|---|
| 08492dd795 | |||
| ecf4ff4938 | |||
| 44a188b7ee | |||
| c70dc99bf9 | |||
| 10671e7b78 | |||
| 754f904e3b | |||
| aeca12b41d | |||
| f4eee2338b | |||
| 65c75dfc8d | |||
| d6b2e417f4 |
@@ -268,6 +268,7 @@ 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/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/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';
|
||||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
|
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
* mobile-bridge, playlist, quotation, spoke) only registered tables and fields
|
* mobile-bridge, playlist, quotation, spoke) only registered tables and fields
|
||||||
* on the partner's behalf. Partner plugins now register themselves with the
|
* on the partner's behalf. Partner plugins now register themselves with the
|
||||||
* TMDO_Anti_EAV_Aware trait (docs/PARTNER-REGISTRATION.md), so these AddOns
|
* TMDO_Anti_EAV_Aware trait (docs/PARTNER-REGISTRATION.md), so these AddOns
|
||||||
* are deprecated and will be removed.
|
* are deprecated and will be removed. The SSO data needs no AddOn at all:
|
||||||
|
* the hub, spoke and SSO AddOns go without replacement (the `sso` group gave
|
||||||
|
* no measurable gain over usermeta) once its values are written back.
|
||||||
*
|
*
|
||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
@@ -23,7 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
final class TMDO_Deprecated_Addons_Notice {
|
final class TMDO_Deprecated_Addons_Notice {
|
||||||
|
|
||||||
/** In-house plugins whose AddOn is replaced by partner self-registration. */
|
/** In-house plugins whose AddOn is replaced by partner self-registration. */
|
||||||
private const SLUGS = array( 'bookings', 'collab', 'hub', 'infocards', 'mobile-bridge', 'playlist', 'quotation', 'spoke' );
|
private const SLUGS = array( 'bookings', 'collab', 'hub', 'infocards', 'mobile-bridge', 'playlist', 'quotation', 'spoke', 'sso' );
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook the notice.
|
* Hook the notice.
|
||||||
@@ -79,5 +81,11 @@ final class TMDO_Deprecated_Addons_Notice {
|
|||||||
),
|
),
|
||||||
esc_html__( '改寫方式見 2meet-data-optimizer 外掛內的 docs/PARTNER-REGISTRATION.md;partner 外掛完成自行註冊後即可停用對應 AddOn。', '2meet-data-optimizer' )
|
esc_html__( '改寫方式見 2meet-data-optimizer 外掛內的 docs/PARTNER-REGISTRATION.md;partner 外掛完成自行註冊後即可停用對應 AddOn。', '2meet-data-optimizer' )
|
||||||
);
|
);
|
||||||
|
if ( array_intersect( $active, array( 'hub', 'spoke', 'sso' ) ) ) {
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-warning"><p>%s</p></div>',
|
||||||
|
esc_html__( 'Hub / Spoke / SSO AddOn 不需替代:2meet-hub-core 與 2meet-spoke-sso 會自行註冊資料表,SSO 值存回 usermeta 即可。停用前先執行 wp tmdo bridge-restore-native user --group=sso --retire。', '2meet-data-optimizer' )
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,6 +303,50 @@ class TMDO_CLI_Post {
|
|||||||
WP_CLI::success( 'Post groups reconciled; tmdo_post_scoped_routing is on.' );
|
WP_CLI::success( 'Post groups reconciled; tmdo_post_scoped_routing is on.' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── post-status-sync ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add post_status / post_date copies (and their index) to every post
|
||||||
|
* group table and fill them from wp_posts.
|
||||||
|
*
|
||||||
|
* Existing tables only get the columns here, never in a web request:
|
||||||
|
* ALTER on a large table can take seconds. Afterwards status changes keep
|
||||||
|
* the copies current. Re-run after bulk writes that bypass WordPress
|
||||||
|
* (e.g. post-reconcile-groups, post migrations).
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Count rows whose copies are missing or stale; change nothing.
|
||||||
|
*
|
||||||
|
* [--batch=<n>]
|
||||||
|
* : Rows per UPDATE. Default 1000.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp tmdo post-status-sync --dry-run
|
||||||
|
* wp tmdo post-status-sync
|
||||||
|
*
|
||||||
|
* @param array $args Positional arguments (unused).
|
||||||
|
* @param array $assoc_args Named arguments.
|
||||||
|
*/
|
||||||
|
public function post_status_sync( $args, $assoc_args ): void {
|
||||||
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
|
$report = TMDO_Post_Status_Sync::run( $dry_run, (int) ( $assoc_args['batch'] ?? 1000 ) );
|
||||||
|
|
||||||
|
if ( empty( $report ) ) {
|
||||||
|
WP_CLI::log( 'No post group tables.' );
|
||||||
|
}
|
||||||
|
foreach ( $report as $group => $r ) {
|
||||||
|
WP_CLI::log( sprintf( '%s: %d row(s), %d missing or stale%s', $group, $r['rows'], $r['mismatched'], $dry_run ? '' : sprintf( ' → %s', TMDO_Post_Status_Sync::state( $group ) ) ) );
|
||||||
|
}
|
||||||
|
if ( $dry_run ) {
|
||||||
|
WP_CLI::success( '[dry-run] Nothing changed.' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WP_CLI::success( 'post_status / post_date copies are in sync.' );
|
||||||
|
}
|
||||||
|
|
||||||
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
|
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -883,3 +927,4 @@ WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_sh
|
|||||||
WP_CLI::add_command( 'tmdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|
WP_CLI::add_command( 'tmdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
|
||||||
WP_CLI::add_command( 'wpdo post-reconcile-groups', array( 'TMDO_CLI_Post', 'post_reconcile_groups' ) );
|
WP_CLI::add_command( 'wpdo post-reconcile-groups', array( 'TMDO_CLI_Post', 'post_reconcile_groups' ) );
|
||||||
WP_CLI::add_command( 'tmdo post-reconcile-groups', array( 'TMDO_CLI_Post', 'post_reconcile_groups' ) );
|
WP_CLI::add_command( 'tmdo post-reconcile-groups', array( 'TMDO_CLI_Post', 'post_reconcile_groups' ) );
|
||||||
|
WP_CLI::add_command( 'tmdo post-status-sync', array( 'TMDO_CLI_Post', 'post_status_sync' ) );
|
||||||
|
|||||||
@@ -170,18 +170,31 @@ class TMDO_CLI_V2 {
|
|||||||
* [--demote]
|
* [--demote]
|
||||||
* : After a complete run, switch the entity to dual_write (both copies kept in step).
|
* : After a complete run, switch the entity to dual_write (both copies kept in step).
|
||||||
*
|
*
|
||||||
|
* [--group=<group>]
|
||||||
|
* : Only this group.
|
||||||
|
*
|
||||||
|
* [--retire]
|
||||||
|
* : With --group, after the write-back: forget the group (registry_meta row)
|
||||||
|
* and rename its flat table to <table>_retired_<Ymd>. Refused while a plugin
|
||||||
|
* still registers the group or native meta differs from the flat table.
|
||||||
|
*
|
||||||
* ## EXAMPLES
|
* ## EXAMPLES
|
||||||
*
|
*
|
||||||
* wp tmdo bridge-restore-native user --dry-run
|
* wp tmdo bridge-restore-native user --dry-run
|
||||||
* wp tmdo bridge-restore-native user --demote
|
* wp tmdo bridge-restore-native user --demote
|
||||||
|
* wp tmdo bridge-restore-native user --group=sso --retire
|
||||||
*/
|
*/
|
||||||
public function bridge_restore_native( $args, $assoc_args ): void {
|
public function bridge_restore_native( $args, $assoc_args ): void {
|
||||||
$entity = (string) ( $args[0] ?? '' );
|
$entity = (string) ( $args[0] ?? '' );
|
||||||
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
||||||
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--batch=<n>] [--demote]' );
|
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--batch=<n>] [--demote] [--group=<group> [--retire]]' );
|
||||||
}
|
}
|
||||||
$dry_run = isset( $assoc_args['dry-run'] );
|
$dry_run = isset( $assoc_args['dry-run'] );
|
||||||
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ) );
|
$group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) );
|
||||||
|
if ( isset( $assoc_args['retire'] ) && '' === $group ) {
|
||||||
|
WP_CLI::error( '--retire needs --group=<group>.' );
|
||||||
|
}
|
||||||
|
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group );
|
||||||
|
|
||||||
foreach ( $report['groups'] as $group => $g ) {
|
foreach ( $report['groups'] as $group => $g ) {
|
||||||
WP_CLI::log( sprintf( '%s/%s: %d row(s)%s', $entity, $group, $g['rows'], $dry_run ? '' : sprintf( ', %d native value(s) written', $g['written'] ) ) );
|
WP_CLI::log( sprintf( '%s/%s: %d row(s)%s', $entity, $group, $g['rows'], $dry_run ? '' : sprintf( ', %d native value(s) written', $g['written'] ) ) );
|
||||||
@@ -190,6 +203,14 @@ class TMDO_CLI_V2 {
|
|||||||
WP_CLI::success( '[dry-run] Nothing written.' );
|
WP_CLI::success( '[dry-run] Nothing written.' );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ( isset( $assoc_args['retire'] ) ) {
|
||||||
|
$retired = TMDO_Native_Restorer::retire( $entity, $group );
|
||||||
|
if ( 'retired' !== $retired['status'] ) {
|
||||||
|
WP_CLI::error( sprintf( 'Not retired (%s%s). Written values stay in native meta.', $retired['status'], $retired['mismatches'] ? ", {$retired['mismatches']} value(s) differ" : '' ) );
|
||||||
|
}
|
||||||
|
WP_CLI::success( "{$entity}/{$group} retired; flat table kept as {$retired['archived_table']}." );
|
||||||
|
return;
|
||||||
|
}
|
||||||
WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' );
|
WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,8 +54,12 @@ SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個
|
|||||||
1. **原生 meta_query 在百萬規模下無法使用。** 任何以 meta 為條件的搜尋都要 14–17 秒,扁平化是必要的,不是錦上添花。
|
1. **原生 meta_query 在百萬規模下無法使用。** 任何以 meta 為條件的搜尋都要 14–17 秒,扁平化是必要的,不是錦上添花。
|
||||||
2. **扁平表的價值集中在「搜尋與篩選」。** 單筆或批次讀 meta(情境 4–6)三種形式都在 1 ms 內,差距可以忽略,而且實務上還有 object cache。
|
2. **扁平表的價值集中在「搜尋與篩選」。** 單筆或批次讀 meta(情境 4–6)三種形式都在 1 ms 內,差距可以忽略,而且實務上還有 object cache。
|
||||||
3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。
|
3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。
|
||||||
|
- **PR-028**:post 群組表可存 `post_status`、`post_date` 副本與 `(post_status, post_date)` 索引(`wp tmdo post-status-sync`)。1M 列實測:加欄位 0.05 秒(instant)、建索引 5.3 秒、分批(1,000 列)回填 32 秒,之後與 `posts` 0 筆不一致。欄位 collation 必須跟 `posts.post_status` 相同,否則比對會出現 illegal mix of collations。查詢改寫在 PR-029。
|
||||||
|
- **PR-029**:副本 ready 且無 NULL 時,單一 post type、明確狀態、依日期排序的搜尋改為 INNER JOIN、`post_status IN (…)`、`ORDER BY` 副本 `post_date`、不 GROUP BY(bench 變體 `entity+copy`,`--iterations=5`)。仍帶 `SQL_CALC_FOUND_ROWS` 時:情境 1 p50 670→770 ms、**p95 3,739→773 ms**;情境 2 p50 356→448 ms(變慢,因為算總數仍要掃完所有符合列)。拿掉 `SQL_CALC_FOUND_ROWS` 後同形狀情境 1 只要 **8 ms**(走 `idx_post_status_date`、LIMIT 提早結束)→ 完整效益要搭配 PR-030。
|
||||||
|
- **PR-030**:路由後的搜尋不再帶 `SQL_CALC_FOUND_ROWS`;總數改由同一查詢的 `COUNT(*)` 回答,存在物件快取,key 綁 `last_changed(posts)`(核心存文、Hook Bus 扁平寫入都會推進)。1M 實測:快取命中時整頁 **8 ms**(原 694 / 363 ms);快取失效後第一次為頁面 8 ms + COUNT 608 / 344 ms,與原本相當。任何 post 寫入都讓全部計數失效(WP last_changed 語意),寫入頻繁的站命中率較低。
|
||||||
4. **Hot 表的索引規則有漏洞。** `add_covering_indexes()` 只替 decimal、tinyint,以及名稱以 `_time` 結尾的 bigint 建索引,所以 `hp_vendor` 沒有索引,情境 3 要 3.4 秒,比 Entity 慢約 12,000 倍。
|
4. **Hot 表的索引規則有漏洞。** `add_covering_indexes()` 只替 decimal、tinyint,以及名稱以 `_time` 結尾的 bigint 建索引,所以 `hp_vendor` 沒有索引,情境 3 要 3.4 秒,比 Entity 慢約 12,000 倍。
|
||||||
5. **Entity 表只有單欄索引。** 情境 2 輸給有複合索引的 Hot 表(337 ms 對 277 ms)。
|
5. **Entity 表只有單欄索引。** 情境 2 輸給有複合索引的 Hot 表(337 ms 對 277 ms)。
|
||||||
|
- **PR-027 之後**:群組可用 `register_group( ..., array( 'indexes' => ... ) )` 宣告複合索引。在 1M 列的 Entity 表加上 `cidx_verified_price (hp_verified, hp_price)` 花 8.1 秒;重跑情境 2(`--iterations=10`),Entity p50 從 337 ms 降到 **278 ms**(p95 285 ms),Hot 是 269 ms(p95 280 ms),差距在量測誤差內。執行計畫改走 `e:cidx_verified_price`。
|
||||||
6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。
|
6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。
|
||||||
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
|
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# ADR-003:Hot zone 與 Entity 表收斂
|
||||||
|
|
||||||
|
- **狀態**:已接受(2026-09-26,選項 A)
|
||||||
|
- **日期**:2026-09-26
|
||||||
|
- **相關**:`BENCHMARK-SCALE.md`(O-3、O-5)、backlog PR-025〜PR-030
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
post 的高頻欄位有兩套扁平化機制,資料可能存兩份:
|
||||||
|
|
||||||
|
| | Hot zone | Entity bridge |
|
||||||
|
|---|---|---|
|
||||||
|
| 表 | `wp_wpdo_hot_{post_type}`(每個 post type 一張) | `wp_wpdo_post_{group}`(每個群組一張) |
|
||||||
|
| 註冊 | `TMDO_Schema_Registry`(zone = hot) | `TMDO_Entity_Registry::register_group()` |
|
||||||
|
| 寫入 | `TMDO_Sync_Bridge`(zone 模組進入 dual_write 後) | `TMDO_Hook_Bus`(實體 mode ≥ dual_write) |
|
||||||
|
| 查詢改寫 | `TMDO_Query_Router`(`hot_*` 模組 cutover 後) | `TMDO_Post_Query_Router`(post mode ≥ shadow_read) |
|
||||||
|
| 生命週期 | 每個 zone 模組 7 狀態 FSM | 每個實體 4 模式(disabled → aeav_only) |
|
||||||
|
| 涵蓋實體 | 只有 post | post、user、term、comment |
|
||||||
|
| 可完全取代原生 meta | 否(cutover 後原生仍保留) | 是(aeav_only) |
|
||||||
|
| 其他 | 單欄 `indexed`、自訂 `data_type` | post_type 範圍(PR-033)、schema hash、衝突偵測、孤兒還原、回寫原生(PR-017/018/031) |
|
||||||
|
|
||||||
|
Warm(瀏覽數 TTL)、Cold(JSON blob)、Archive(已刪除文章的 meta)是 Entity 沒有的能力,**不在本決策範圍**,保持原狀。
|
||||||
|
|
||||||
|
## 事實
|
||||||
|
|
||||||
|
**效能**(100k vendor / 1M listing,`BENCHMARK-SCALE.md`):
|
||||||
|
|
||||||
|
| 情境 | Hot | Entity |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 價格區間搜尋 p50 | 558 ms | 652 ms |
|
||||||
|
| 價格區間 + verified p50 | **277 ms**(有複合索引) | 337 ms |
|
||||||
|
| vendor 名下 listing p50 | 3,405 ms(無索引) | **0.28 ms** |
|
||||||
|
| 單筆 / 列表預載 | 相同 | 相同 |
|
||||||
|
| 容量 | 229 MB | 379 MB |
|
||||||
|
|
||||||
|
唯一 Hot 領先的是有複合索引的情境;Entity 支援複合索引後(PR-027)即可追平。
|
||||||
|
|
||||||
|
**各站使用現況**(2026-09-26 唯讀查詢):
|
||||||
|
|
||||||
|
| 站 | Hot / Cold 表 | zone 模組 | post entity |
|
||||||
|
|---|---|---|---|
|
||||||
|
| dev20 / dev21 / dev22 | 無 | 全 idle | aeav_only(dev21 約 400 萬列) |
|
||||||
|
| dev23 / 24 / 30 / 31 / 40 | 無 | 全 idle | disabled |
|
||||||
|
| dev34(舊 WPDO 1.3.37) | hot_hp_listing 486、hot_hp_vendor 233 | cutover | disabled |
|
||||||
|
| dev10 DB(舊版) | hot_hp_listing 491、hot_hp_vendor 231 | cutover | aeav_only(約 3,900 列) |
|
||||||
|
|
||||||
|
- **沒有任何 2meet-DO 站在用 Hot zone**;有扁平資料的站全部走 Entity。
|
||||||
|
- 只有兩個舊系統的站有 Hot 資料,而它們在 cutover 仍雙寫 postmeta,Hot 表只是副本(PR-022 已確認以 postmeta 為準)。
|
||||||
|
|
||||||
|
**只由 Hot zone 註冊、Entity 群組沒有涵蓋的 HP 欄位**:listing / request 的 `hp_drafted`;bookings 的 `hp_start_time`、`hp_end_time`、`hp_quantity`;memberships 的 `hp_expire_period`、`hp_primary`、`hp_expired_time`。
|
||||||
|
|
||||||
|
## 選項
|
||||||
|
|
||||||
|
**A. 收斂到 Entity(建議)**
|
||||||
|
post 高頻欄位只用 Entity。Hot zone 凍結:不再新增註冊,程式碼暫留只為讀取舊資料,確認沒有站使用後再移除。
|
||||||
|
|
||||||
|
- 優點:一套路由、一份資料;Entity 能力是 Hot 的超集;現況已經全部在用 Entity,不需要搬資料。
|
||||||
|
- 代價:Hot 獨有的欄位(上面那幾個)要改成新的 Entity 群組;情境 2 要靠 PR-027 的複合索引追平。
|
||||||
|
|
||||||
|
**B. 兩套並存**
|
||||||
|
維持現狀。代價是兩套寫入和查詢路徑都要維護,同一個 key 可能存兩份,O-3 之類的最佳化要做兩次。
|
||||||
|
|
||||||
|
**C. 收斂到 Hot**
|
||||||
|
不可行:Hot 只支援 post,也沒有 aeav_only、範圍宣告、回寫工具。
|
||||||
|
|
||||||
|
## 建議(選項 A)的後續步驟
|
||||||
|
|
||||||
|
1. **PR-026(Hot 表 bigint 索引)標為 `[!]` 不做**:沒有站在用 Hot zone。
|
||||||
|
2. **PR-027(Entity 複合索引)照常做**:這是情境 2 追平 Hot 的方式。
|
||||||
|
3. **新增 PR(HP AddOn)**:把 Hot 獨有的欄位改註冊成新的 Entity 群組(例如 `hp_booking_core`、`hp_membership_core`,以及 listing / request 的補充群組);既有群組的定義不能改。
|
||||||
|
4. **新增 PR(HP AddOn)**:HP adapter 停止註冊 hot 欄位;核心對 zone = hot 的新註冊記錄 deprecated 警告。
|
||||||
|
5. **之後**:dev34(PR-022)和 dev10 DB(PR-023)遷出後,再評估移除 `TMDO_Zone_Hot`、`TMDO_Sync_Bridge` 的 hot 路徑與 `TMDO_Query_Router`。
|
||||||
|
|
||||||
|
## 決定
|
||||||
|
|
||||||
|
**選項 A:收斂到 Entity。**(使用者 2026-09-26 決定)
|
||||||
|
|
||||||
|
Hot zone 凍結,依上面的後續步驟執行;backlog 已同步調整(PR-026 標為 `[!]`,新增 HP AddOn 的兩個 PR)。
|
||||||
@@ -109,6 +109,9 @@ class TMDO_Core {
|
|||||||
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
|
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
|
||||||
TMDO_Hook_Bus_Bridge::maybe_init_hook_bus();
|
TMDO_Hook_Bus_Bridge::maybe_init_hook_bus();
|
||||||
|
|
||||||
|
// Keep post_status / post_date copies on post entity tables current.
|
||||||
|
TMDO_Post_Status_Sync::register();
|
||||||
|
|
||||||
// Register audit log listeners for wpdo_after_write / wpdo_after_delete.
|
// Register audit log listeners for wpdo_after_write / wpdo_after_delete.
|
||||||
if ( class_exists( 'TMDO_Audit_Logger' ) ) {
|
if ( class_exists( 'TMDO_Audit_Logger' ) ) {
|
||||||
TMDO_Audit_Logger::init();
|
TMDO_Audit_Logger::init();
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ class TMDO_Schema_Registry {
|
|||||||
*/
|
*/
|
||||||
private array $warm_fields = array();
|
private array $warm_fields = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Providers already told the Hot zone is frozen (once per request).
|
||||||
|
*
|
||||||
|
* @var array<string, true>
|
||||||
|
*/
|
||||||
|
private array $hot_deprecation_flagged = array();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private constructor — use instance() to get the singleton.
|
* Private constructor — use instance() to get the singleton.
|
||||||
*/
|
*/
|
||||||
@@ -138,6 +145,7 @@ class TMDO_Schema_Registry {
|
|||||||
switch ( $config['zone'] ) {
|
switch ( $config['zone'] ) {
|
||||||
case 'hot':
|
case 'hot':
|
||||||
$this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type'];
|
$this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type'];
|
||||||
|
$this->flag_hot_deprecation( $provider, $config['meta_key'] );
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'warm':
|
case 'warm':
|
||||||
@@ -159,6 +167,29 @@ class TMDO_Schema_Registry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Hot zone is frozen (ADR-003): fields still register so existing hot
|
||||||
|
* data stays readable, but each provider gets one notice per request.
|
||||||
|
*
|
||||||
|
* @param string $provider Provider name.
|
||||||
|
* @param string $meta_key First hot meta_key seen from it.
|
||||||
|
*/
|
||||||
|
private function flag_hot_deprecation( string $provider, string $meta_key ): void {
|
||||||
|
if ( isset( $this->hot_deprecation_flagged[ $provider ] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->hot_deprecation_flagged[ $provider ] = true;
|
||||||
|
_doing_it_wrong(
|
||||||
|
__CLASS__ . '::register',
|
||||||
|
sprintf(
|
||||||
|
'Provider "%1$s" registered Hot zone field "%2$s". The Hot zone is frozen; register an entity group (TMDO_Entity_Registry::register_group) instead.',
|
||||||
|
esc_html( $provider ),
|
||||||
|
esc_html( $meta_key )
|
||||||
|
),
|
||||||
|
esc_html( TMDO_VERSION )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk register multiple fields.
|
* Bulk register multiple fields.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ final class TMDO_Entity_Registry {
|
|||||||
/** @var array<string, array<string, string[]>> 實體類型 → 群組名 → 限定的 post_type */
|
/** @var array<string, array<string, string[]>> 實體類型 → 群組名 → 限定的 post_type */
|
||||||
private static array $scopes = array();
|
private static array $scopes = array();
|
||||||
|
|
||||||
|
/** @var array<string, array<string, array<string, string[]>>> 實體類型 → 群組名 → 索引名 → 欄位 key */
|
||||||
|
private static array $indexes = array();
|
||||||
|
|
||||||
/** @var array<string, array> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
|
/** @var array<string, array> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
|
||||||
private static array $collisions = array();
|
private static array $collisions = array();
|
||||||
|
|
||||||
@@ -74,6 +77,7 @@ final class TMDO_Entity_Registry {
|
|||||||
self::$unscoped_index = array();
|
self::$unscoped_index = array();
|
||||||
self::$scoped_index = array();
|
self::$scoped_index = array();
|
||||||
self::$scopes = array();
|
self::$scopes = array();
|
||||||
|
self::$indexes = array();
|
||||||
self::$pending_schemas = array();
|
self::$pending_schemas = array();
|
||||||
self::$collisions = array();
|
self::$collisions = array();
|
||||||
self::$restored = array();
|
self::$restored = array();
|
||||||
@@ -119,6 +123,9 @@ final class TMDO_Entity_Registry {
|
|||||||
* 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬;
|
* 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬;
|
||||||
* 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。
|
* 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。
|
||||||
* 不列入欄位定義,所以不影響 schema hash 與衝突比對。
|
* 不列入欄位定義,所以不影響 schema hash 與衝突比對。
|
||||||
|
* indexes (array<string, string[]>) — 複合索引:名稱 → 欄位 key(依序);
|
||||||
|
* 含未知欄位的索引會被略過。同樣不影響 schema hash,
|
||||||
|
* 由 TMDO_Schema_Manager::ensure_group_indexes() 補建到既有表。
|
||||||
*
|
*
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
@@ -151,6 +158,13 @@ final class TMDO_Entity_Registry {
|
|||||||
if ( $post_types ) {
|
if ( $post_types ) {
|
||||||
self::$scopes[ $entity_type ][ $group_name ] = $post_types;
|
self::$scopes[ $entity_type ][ $group_name ] = $post_types;
|
||||||
}
|
}
|
||||||
|
$keys = array_column( $normalized, 'key' );
|
||||||
|
foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) {
|
||||||
|
$columns = array_values( array_map( 'strval', (array) $columns ) );
|
||||||
|
if ( $columns && ! array_diff( $columns, $keys ) ) {
|
||||||
|
self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns;
|
||||||
|
}
|
||||||
|
}
|
||||||
foreach ( $normalized as $normalized_field ) {
|
foreach ( $normalized as $normalized_field ) {
|
||||||
self::index_field( $entity_type, $normalized_field, $post_types );
|
self::index_field( $entity_type, $normalized_field, $post_types );
|
||||||
}
|
}
|
||||||
@@ -372,6 +386,15 @@ final class TMDO_Entity_Registry {
|
|||||||
return array_keys( self::$groups[ $entity_type ] ?? array() );
|
return array_keys( self::$groups[ $entity_type ] ?? array() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 群組宣告的複合索引。
|
||||||
|
*
|
||||||
|
* @return array<string, string[]> 索引名 → 欄位 key
|
||||||
|
*/
|
||||||
|
public static function get_group_indexes( string $entity_type, string $group_name ): array {
|
||||||
|
return self::$indexes[ $entity_type ][ $group_name ] ?? array();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 群組限定的 post_type(未限定時為空陣列)。
|
* 群組限定的 post_type(未限定時為空陣列)。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -328,19 +328,25 @@ final class TMDO_Hook_Bus {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 單一 upsert,避免 SELECT→UPDATE/INSERT 的 read-modify-write race。
|
// 單一 upsert,避免 SELECT→UPDATE/INSERT 的 read-modify-write race。
|
||||||
|
// post:新列一併帶入 post_status / post_date 副本(只在 insert 時寫入)。
|
||||||
|
$extra = 'post' === $type ? TMDO_Post_Status_Sync::insert_columns( $entity_id, $group ) : array();
|
||||||
$result = TMDO_DB::upsert(
|
$result = TMDO_DB::upsert(
|
||||||
$table,
|
$table,
|
||||||
|
array_merge(
|
||||||
array(
|
array(
|
||||||
$id_col => $entity_id,
|
$id_col => $entity_id,
|
||||||
$col => $db_value,
|
$col => $db_value,
|
||||||
),
|
),
|
||||||
|
$extra
|
||||||
|
),
|
||||||
array( $col ),
|
array( $col ),
|
||||||
$id_col,
|
$id_col,
|
||||||
array( '%d', $format )
|
array_merge( array( '%d', $format ), array_fill( 0, count( $extra ), '%s' ) )
|
||||||
);
|
);
|
||||||
|
|
||||||
// 清除快取
|
// 清除快取
|
||||||
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
|
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
|
||||||
|
self::touch_posts_last_changed( $type );
|
||||||
|
|
||||||
// 短路回傳 true(WP 認為寫入成功)
|
// 短路回傳 true(WP 認為寫入成功)
|
||||||
return $result !== false;
|
return $result !== false;
|
||||||
@@ -569,6 +575,8 @@ final class TMDO_Hook_Bus {
|
|||||||
TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group );
|
TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::touch_posts_last_changed( $type );
|
||||||
|
|
||||||
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
|
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
|
||||||
|
|
||||||
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
|
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
|
||||||
@@ -608,6 +616,20 @@ final class TMDO_Hook_Bus {
|
|||||||
// 工具方法
|
// 工具方法
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat post writes skip update_metadata()'s own hooks in aeav_only, so
|
||||||
|
* move posts' last_changed here: caches keyed on it (WP query caches,
|
||||||
|
* routed found-rows counts) must not outlive the write.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function touch_posts_last_changed( string $type ): void {
|
||||||
|
if ( 'post' === $type ) {
|
||||||
|
wp_cache_set_posts_last_changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 從當前 filter 名稱推斷實體類型
|
* 從當前 filter 名稱推斷實體類型
|
||||||
* 例:update_user_metadata → user
|
* 例:update_user_metadata → user
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Copies of posts.post_status / post_date on post entity tables (O-1).
|
||||||
|
*
|
||||||
|
* With the copies (and their `(post_status, post_date)` index) a routed search
|
||||||
|
* can filter and sort inside the flat table instead of joining back to posts.
|
||||||
|
*
|
||||||
|
* Columns only reach a table two ways, never as DDL on a populated table in a
|
||||||
|
* web request:
|
||||||
|
* - a brand-new (empty) group table gets them when it is created;
|
||||||
|
* - `wp tmdo post-status-sync` adds them to existing tables and backfills.
|
||||||
|
*
|
||||||
|
* Per-group state lives in the TMDO_Post_Status_Sync::OPTION option:
|
||||||
|
* 'columns' (added, backfill pending) or 'ready' (every row copied).
|
||||||
|
* Afterwards transition_post_status keeps the copies current, and flat writes
|
||||||
|
* that create a row fill them in.
|
||||||
|
*
|
||||||
|
* @package TMDO
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps post_status / post_date copies on post entity tables.
|
||||||
|
*/
|
||||||
|
final class TMDO_Post_Status_Sync {
|
||||||
|
|
||||||
|
public const OPTION = 'tmdo_post_status_columns';
|
||||||
|
|
||||||
|
private const INDEX = 'idx_post_status_date';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook the sync into WordPress.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function register(): void {
|
||||||
|
add_action( 'transition_post_status', array( __CLASS__, 'on_transition' ), 10, 3 );
|
||||||
|
add_action( 'wpdo_schema_updated', array( __CLASS__, 'on_schema_updated' ), 10, 3 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* '' (no columns), 'columns' or 'ready'.
|
||||||
|
*
|
||||||
|
* @param string $group Post group name.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function state( string $group ): string {
|
||||||
|
$states = get_option( self::OPTION, array() );
|
||||||
|
return is_array( $states ) ? (string) ( $states[ $group ] ?? '' ) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every post status change or save: refresh the copies in each table that has them.
|
||||||
|
*
|
||||||
|
* @param string $new_status New status.
|
||||||
|
* @param string $old_status Old status.
|
||||||
|
* @param object $post Saved post.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function on_transition( $new_status, $old_status, $post ): void {
|
||||||
|
global $wpdb;
|
||||||
|
unset( $new_status, $old_status );
|
||||||
|
foreach ( self::groups_with_columns() as $group ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$post->post_status,
|
||||||
|
$post->post_date,
|
||||||
|
$post->ID
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A post group table was just created or altered: an empty one gets the
|
||||||
|
* columns now; one holding rows waits for the CLI.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $table Table name.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function on_schema_updated( $entity_type, $group, $table ): void {
|
||||||
|
global $wpdb;
|
||||||
|
if ( 'post' !== $entity_type || '' !== self::state( $group ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( self::add_columns( $group ) ) {
|
||||||
|
self::set_state( $group, 'ready' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies for a row a flat write is about to create (empty when the
|
||||||
|
* group's table has no such columns).
|
||||||
|
*
|
||||||
|
* @param int $post_id Post ID.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return array{post_status?:string, post_date?:string}
|
||||||
|
*/
|
||||||
|
public static function insert_columns( int $post_id, string $group ): array {
|
||||||
|
if ( '' === self::state( $group ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$post = get_post( $post_id );
|
||||||
|
if ( ! $post ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'post_status' => $post->post_status,
|
||||||
|
'post_date' => $post->post_date,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the columns to every post group table and copy posts' values in.
|
||||||
|
*
|
||||||
|
* @param bool $dry_run Count mismatching rows only.
|
||||||
|
* @param int $batch Rows per UPDATE.
|
||||||
|
* @return array<string, array{rows:int, mismatched:int}>
|
||||||
|
*/
|
||||||
|
public static function run( bool $dry_run = false, int $batch = 1000 ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$batch = max( 1, $batch );
|
||||||
|
$report = array();
|
||||||
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$report[ $group ] = array(
|
||||||
|
'rows' => $rows,
|
||||||
|
'mismatched' => '' === self::state( $group ) ? $rows : self::mismatches( $group ),
|
||||||
|
);
|
||||||
|
if ( $dry_run || ! self::add_columns( $group ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$max = (int) $wpdb->get_var( "SELECT MAX(id) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
for ( $from = 0; $from < $max; $from += $batch ) {
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id SET t.post_status = p.post_status, t.post_date = p.post_date WHERE t.id > %d AND t.id <= %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$from,
|
||||||
|
$from + $batch
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self::set_state( $group, 'ready' );
|
||||||
|
}
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows whose copies differ from posts (rows of deleted posts excluded).
|
||||||
|
*
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public static function mismatches( string $group ): int {
|
||||||
|
global $wpdb;
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
return (int) $wpdb->get_var(
|
||||||
|
"SELECT COUNT(*) FROM `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id WHERE NOT ( t.post_status <=> p.post_status ) OR NOT ( t.post_date <=> p.post_date )" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add whichever of the two columns and the index the table lacks.
|
||||||
|
*
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return bool False when the table is missing or the ALTER failed.
|
||||||
|
*/
|
||||||
|
private static function add_columns( string $group ): bool {
|
||||||
|
global $wpdb;
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$indexes = array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
|
||||||
|
$changes = array();
|
||||||
|
if ( ! in_array( 'post_status', $columns, true ) ) {
|
||||||
|
// Same collation as posts.post_status: the table default may differ,
|
||||||
|
// and comparing the two columns would then fail (illegal mix of collations).
|
||||||
|
$source = $wpdb->get_row( "SHOW FULL COLUMNS FROM `{$wpdb->posts}` LIKE 'post_status'", ARRAY_A );
|
||||||
|
$collation = preg_replace( '/[^A-Za-z0-9_]/', '', (string) ( $source['Collation'] ?? '' ) );
|
||||||
|
$changes[] = 'ADD COLUMN `post_status` VARCHAR(20)' . ( '' !== $collation ? " COLLATE {$collation}" : '' ) . ' DEFAULT NULL';
|
||||||
|
}
|
||||||
|
if ( ! in_array( 'post_date', $columns, true ) ) {
|
||||||
|
$changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL';
|
||||||
|
}
|
||||||
|
if ( ! in_array( self::INDEX, $indexes, true ) ) {
|
||||||
|
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`)';
|
||||||
|
}
|
||||||
|
if ( $changes && false === $wpdb->query( "ALTER TABLE `{$table}` " . implode( ', ', $changes ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fixed DDL on a registry table name.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ( '' === self::state( $group ) ) {
|
||||||
|
self::set_state( $group, 'columns' );
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post groups whose table has the columns.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function groups_with_columns(): array {
|
||||||
|
$states = get_option( self::OPTION, array() );
|
||||||
|
return is_array( $states ) ? array_keys( $states ) : array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a group's state.
|
||||||
|
*
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @param string $state 'columns' | 'ready'.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function set_state( string $group, string $state ): void {
|
||||||
|
$states = get_option( self::OPTION, array() );
|
||||||
|
$states = is_array( $states ) ? $states : array();
|
||||||
|
$states[ $group ] = $state;
|
||||||
|
update_option( self::OPTION, $states, true );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ defined( 'ABSPATH' ) || exit;
|
|||||||
|
|
||||||
final class TMDO_Schema_Manager {
|
final class TMDO_Schema_Manager {
|
||||||
|
|
||||||
|
/** Option: "entity/group" → md5 of the composite index definition already applied. */
|
||||||
|
public const INDEXES_OPTION = 'tmdo_entity_indexes';
|
||||||
|
|
||||||
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
|
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
|
||||||
private static array $table_exists_cache = array();
|
private static array $table_exists_cache = array();
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ final class TMDO_Schema_Manager {
|
|||||||
$schema['group'],
|
$schema['group'],
|
||||||
$schema['fields']
|
$schema['fields']
|
||||||
);
|
);
|
||||||
|
self::ensure_group_indexes( $schema['type'], $schema['group'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
TMDO_Entity_Registry::clear_pending_schemas();
|
TMDO_Entity_Registry::clear_pending_schemas();
|
||||||
@@ -164,6 +168,50 @@ final class TMDO_Schema_Manager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the group's declared composite indexes that its table lacks.
|
||||||
|
*
|
||||||
|
* Indexes are not part of the schema hash, so an existing table (which
|
||||||
|
* may already hold aeav_only data) gets them in place — no rebuild. The
|
||||||
|
* applied definition is remembered per group (one autoloaded option), so
|
||||||
|
* requests only run SHOW INDEX / ALTER when the declaration changes.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group_name Group name.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function ensure_group_indexes( string $entity_type, string $group_name ): void {
|
||||||
|
global $wpdb;
|
||||||
|
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
|
||||||
|
if ( empty( $declared ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$id = "{$entity_type}/{$group_name}";
|
||||||
|
$signature = md5( (string) wp_json_encode( $declared ) );
|
||||||
|
$applied = get_option( self::INDEXES_OPTION, array() );
|
||||||
|
$applied = is_array( $applied ) ? $applied : array();
|
||||||
|
if ( ( $applied[ $id ] ?? '' ) === $signature ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$table = self::get_table_name( $entity_type, $group_name );
|
||||||
|
if ( ! self::table_exists( $table ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
|
||||||
|
foreach ( $declared as $name => $keys ) {
|
||||||
|
$index = 'cidx_' . self::sanitize_column_name( $name );
|
||||||
|
if ( in_array( $index, $existing, true ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
|
||||||
|
$wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" );
|
||||||
|
}
|
||||||
|
|
||||||
|
$applied[ $id ] = $signature;
|
||||||
|
update_option( self::INDEXES_OPTION, $applied, true );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 計算 Schema Hash(用於偵測欄位變動)
|
* 計算 Schema Hash(用於偵測欄位變動)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers four user entity groups for membership, activity, profile, and SSO.
|
* Registers the user entity groups for membership, activity and profile.
|
||||||
*
|
*
|
||||||
* Groups → flat tables:
|
* Groups → flat tables:
|
||||||
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
|
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
|
||||||
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
|
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
|
||||||
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
|
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
|
||||||
* sso → wp_wpdo_user_sso (Hub token cache, replaces _tmso_* usermeta)
|
* (sso → wp_wpdo_user_sso is owned by 2meet-data-optimizer-sso-addon)
|
||||||
*
|
*
|
||||||
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
|
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
|
||||||
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
|
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
|
||||||
@@ -61,7 +61,6 @@ final class TMDO_Member_Fields {
|
|||||||
self::register_membership_group();
|
self::register_membership_group();
|
||||||
self::register_activity_group();
|
self::register_activity_group();
|
||||||
self::register_profile_group();
|
self::register_profile_group();
|
||||||
self::register_sso_group();
|
|
||||||
self::register_core_profile_group();
|
self::register_core_profile_group();
|
||||||
self::register_social_group();
|
self::register_social_group();
|
||||||
self::register_commerce_group();
|
self::register_commerce_group();
|
||||||
@@ -213,70 +212,6 @@ final class TMDO_Member_Fields {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hub/Spoke SSO token cache — replaces _tmso_* usermeta.
|
|
||||||
*
|
|
||||||
* Silent refresh fires every 15 min per user; at 10M users this is a
|
|
||||||
* high-frequency EAV hot-spot. A flat table + object cache hit cuts DB
|
|
||||||
* load 10–50× versus a wp_usermeta EAV scan per refresh.
|
|
||||||
*
|
|
||||||
* last_id_token is NOT stored in plaintext (privacy + volume). Only the
|
|
||||||
* SHA-256 hash is kept for SLO token comparison.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private static function register_sso_group(): void {
|
|
||||||
TMDO_Entity_Registry::register_group(
|
|
||||||
'user',
|
|
||||||
'sso',
|
|
||||||
array(
|
|
||||||
array(
|
|
||||||
'key' => 'hub_global_user_id',
|
|
||||||
'type' => 'text',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'Hub global user UUID (2mso_user_mapping.global_user_id bridge key)',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'picture_url',
|
|
||||||
'type' => 'text',
|
|
||||||
'label' => 'Social / SSO profile picture URL',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'last_id_token_hash',
|
|
||||||
'type' => 'text',
|
|
||||||
'label' => 'SHA-256(last_id_token) for SLO comparison — no plaintext stored',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'refresh_token_enc',
|
|
||||||
'type' => 'textarea',
|
|
||||||
'label' => 'Encrypted refresh token (TMSO_Crypto — key-versioned enc_vN:ciphertext)',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'token_expires_at',
|
|
||||||
'type' => 'datetime',
|
|
||||||
'searchable' => true,
|
|
||||||
'label' => 'SSO token expiry (set to past to force re-auth on next request)',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'sso_last_login_at',
|
|
||||||
'type' => 'datetime',
|
|
||||||
'label' => 'Last SSO-initiated login datetime',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'sso_login_count',
|
|
||||||
'type' => 'integer',
|
|
||||||
'default' => 0,
|
|
||||||
'label' => 'SSO login count',
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'key' => 'known_login_ips',
|
|
||||||
'type' => 'textarea',
|
|
||||||
'label' => 'Recent login IPs JSON array (new-device notification) — replaces _tmso_known_ips usermeta',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WP core user fields stored as multi-row EAV in wp_usermeta.
|
* WP core user fields stored as multi-row EAV in wp_usermeta.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
* $demote the entity then moves to dual_write, which keeps both copies in
|
* $demote the entity then moves to dual_write, which keeps both copies in
|
||||||
* step from then on.
|
* step from then on.
|
||||||
*
|
*
|
||||||
|
* A single group can be written back on its own ($group) and then retired:
|
||||||
|
* retire() forgets its registry_meta row and keeps the flat table under a
|
||||||
|
* `_retired_<Ymd>` name, so orphan restore never routes its keys again.
|
||||||
|
*
|
||||||
* @package WP_Data_Optimizer
|
* @package WP_Data_Optimizer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -47,9 +51,10 @@ final class TMDO_Native_Restorer {
|
|||||||
* @param bool $dry_run Count only; write nothing.
|
* @param bool $dry_run Count only; write nothing.
|
||||||
* @param int $batch Rows per batch.
|
* @param int $batch Rows per batch.
|
||||||
* @param bool $demote After a complete run, switch the entity to dual_write.
|
* @param bool $demote After a complete run, switch the entity to dual_write.
|
||||||
|
* @param string $group Only this group ('' = every group).
|
||||||
* @return array{groups: array<string, array{rows:int, written:int}>, demoted: bool}
|
* @return array{groups: array<string, array{rows:int, written:int}>, demoted: bool}
|
||||||
*/
|
*/
|
||||||
public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false ): array {
|
public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '' ): array {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
$report = array(
|
$report = array(
|
||||||
@@ -73,6 +78,9 @@ final class TMDO_Native_Restorer {
|
|||||||
$cursor = is_array( $cursor ) ? $cursor : array();
|
$cursor = is_array( $cursor ) ? $cursor : array();
|
||||||
|
|
||||||
$tables = self::group_tables( $entity_type );
|
$tables = self::group_tables( $entity_type );
|
||||||
|
if ( '' !== $group ) {
|
||||||
|
$tables = array_intersect_key( $tables, array( $group => true ) );
|
||||||
|
}
|
||||||
foreach ( $tables as $group => $table ) {
|
foreach ( $tables as $group => $table ) {
|
||||||
$entry = array(
|
$entry = array(
|
||||||
'rows' => 0,
|
'rows' => 0,
|
||||||
@@ -121,6 +129,75 @@ final class TMDO_Native_Restorer {
|
|||||||
return $report;
|
return $report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget a group that no plugin registers any more, once native meta holds
|
||||||
|
* every non-NULL value of its flat table (run() with $group first).
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return array{status:string, mismatches:int, archived_table:string}
|
||||||
|
* status: retired | registered (a plugin still registers it) |
|
||||||
|
* missing (no registry_meta row or flat table) | mismatch (native differs).
|
||||||
|
*/
|
||||||
|
public static function retire( string $entity_type, string $group ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$result = array(
|
||||||
|
'status' => 'missing',
|
||||||
|
'mismatches' => 0,
|
||||||
|
'archived_table' => '',
|
||||||
|
);
|
||||||
|
$restored = array_map( static fn( $r ) => $r['entity_type'] . '/' . $r['group'], TMDO_Entity_Registry::get_restored_groups() );
|
||||||
|
if ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) && ! in_array( "{$entity_type}/{$group}", $restored, true ) ) {
|
||||||
|
$result['status'] = 'registered';
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
|
||||||
|
$meta_table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
|
||||||
|
$defs = $wpdb->get_var( $wpdb->prepare( "SELECT field_definitions FROM `{$meta_table}` WHERE entity_type = %s AND group_name = %s", $entity_type, $group ) );
|
||||||
|
$fields = json_decode( (string) $defs, true );
|
||||||
|
if ( ! $adapter || ! is_array( $fields ) || null === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$native_table = $adapter->get_native_meta_table();
|
||||||
|
$id_col = $adapter->get_entity_id_column();
|
||||||
|
foreach ( (array) $wpdb->get_results( "SELECT * FROM `{$table}`", ARRAY_A ) as $row ) {
|
||||||
|
foreach ( $fields as $field ) {
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( (string) ( $field['key'] ?? '' ) );
|
||||||
|
if ( '' === $col || ! isset( $row[ $col ] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$want = (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) );
|
||||||
|
// get_results(), not get_col(): wpdb reads an empty string as null.
|
||||||
|
$native = array_column( (array) $wpdb->get_results( $wpdb->prepare( "SELECT meta_value FROM `{$native_table}` WHERE `{$id_col}` = %d AND meta_key = %s", (int) $row[ $id_col ], $field['key'] ), ARRAY_A ), 'meta_value' );
|
||||||
|
if ( array( $want ) !== $native ) {
|
||||||
|
++$result['mismatches'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( $result['mismatches'] ) {
|
||||||
|
$result['status'] = 'mismatch';
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$archived = $table . '_retired_' . gmdate( 'Ymd' );
|
||||||
|
$wpdb->query( "RENAME TABLE `{$table}` TO `{$archived}`" );
|
||||||
|
$wpdb->delete(
|
||||||
|
$meta_table,
|
||||||
|
array(
|
||||||
|
'entity_type' => $entity_type,
|
||||||
|
'group_name' => $group,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
delete_transient( 'tmdo_orphan_native_keys' );
|
||||||
|
TMDO_Schema_Manager::flush_table_exists_cache();
|
||||||
|
|
||||||
|
$result['status'] = 'retired';
|
||||||
|
$result['archived_table'] = $archived;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registered groups of the entity whose flat table exists.
|
* Registered groups of the entity whose flat table exists.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,6 +20,18 @@
|
|||||||
* 3. posts_where — append WHERE conditions targeting flat columns
|
* 3. posts_where — append WHERE conditions targeting flat columns
|
||||||
* 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication
|
* 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication
|
||||||
*
|
*
|
||||||
|
* Status / date on the flat table (O-1): when the group's post_status /
|
||||||
|
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
|
||||||
|
* a single-post-type search with explicit statuses and date ordering
|
||||||
|
* INNER JOINs the table, filters the copies, orders by the copied
|
||||||
|
* post_date and skips GROUP BY (one row per post), so MySQL can walk
|
||||||
|
* `idx_post_status_date` and stop at the LIMIT.
|
||||||
|
*
|
||||||
|
* Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every
|
||||||
|
* match inside the page query); found_posts_query answers from a COUNT of the
|
||||||
|
* same query, kept in the object cache until posts' last_changed moves (core
|
||||||
|
* post saves, and flat writes via TMDO_Hook_Bus).
|
||||||
|
*
|
||||||
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
|
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
|
||||||
* only entity_type='post'.
|
* only entity_type='post'.
|
||||||
*
|
*
|
||||||
@@ -43,6 +55,22 @@ class TMDO_Post_Query_Router {
|
|||||||
/** Query var key for capturing routed clauses (consumed by posts_join/where). */
|
/** Query var key for capturing routed clauses (consumed by posts_join/where). */
|
||||||
private const QUERY_VAR = 'wpdo_post_clauses';
|
private const QUERY_VAR = 'wpdo_post_clauses';
|
||||||
|
|
||||||
|
/** Query var holding the status / date copy route, when one applies. */
|
||||||
|
private const COPY_VAR = 'wpdo_post_copy_route';
|
||||||
|
|
||||||
|
/** Query var holding the COUNT for found_posts_query. */
|
||||||
|
private const COUNT_VAR = 'wpdo_post_count_sql';
|
||||||
|
|
||||||
|
/** Object cache group for found-rows counts. */
|
||||||
|
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request answer to "are this group's copies usable?".
|
||||||
|
*
|
||||||
|
* @var array<string, bool>
|
||||||
|
*/
|
||||||
|
private static array $copies_usable = array();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all WP_Query hooks.
|
* Register all WP_Query hooks.
|
||||||
*
|
*
|
||||||
@@ -53,6 +81,9 @@ class TMDO_Post_Query_Router {
|
|||||||
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
|
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
|
||||||
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
|
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
|
||||||
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
|
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
|
||||||
|
add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
|
||||||
|
add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 );
|
||||||
|
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +165,11 @@ class TMDO_Post_Query_Router {
|
|||||||
|
|
||||||
$query->set( 'meta_query', $remaining );
|
$query->set( 'meta_query', $remaining );
|
||||||
$query->set( self::QUERY_VAR, $routed );
|
$query->set( self::QUERY_VAR, $routed );
|
||||||
|
|
||||||
|
$copy = self::copy_route( $query, $routed );
|
||||||
|
if ( $copy ) {
|
||||||
|
$query->set( self::COPY_VAR, $copy );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,7 +203,8 @@ class TMDO_Post_Query_Router {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$join .= " LEFT JOIN `{$table}` AS `{$alias}` "
|
$type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT';
|
||||||
|
$join .= " {$type} JOIN `{$table}` AS `{$alias}` "
|
||||||
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,9 +238,31 @@ class TMDO_Post_Query_Router {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$copy = $query->get( self::COPY_VAR );
|
||||||
|
if ( ! empty( $copy ) ) {
|
||||||
|
$alias = self::table_alias( $copy['post_type'], $copy['group'] );
|
||||||
|
$where .= self::build_condition( "`{$alias}`.`post_status`", 'IN', $copy['statuses'] );
|
||||||
|
}
|
||||||
|
|
||||||
return $where;
|
return $where;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order by the flat table's post_date copy when the copy route applies.
|
||||||
|
*
|
||||||
|
* @param string|null $orderby Current ORDER BY SQL.
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @return string Modified ORDER BY SQL.
|
||||||
|
*/
|
||||||
|
public function posts_orderby( ?string $orderby, \WP_Query $query ): string {
|
||||||
|
$orderby = (string) ( $orderby ?? '' );
|
||||||
|
$copy = $query->get( self::COPY_VAR );
|
||||||
|
if ( empty( $copy ) ) {
|
||||||
|
return $orderby;
|
||||||
|
}
|
||||||
|
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
|
* Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
|
||||||
*
|
*
|
||||||
@@ -214,7 +273,7 @@ class TMDO_Post_Query_Router {
|
|||||||
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
|
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
|
||||||
$groupby = (string) ( $groupby ?? '' );
|
$groupby = (string) ( $groupby ?? '' );
|
||||||
$routed = $query->get( self::QUERY_VAR );
|
$routed = $query->get( self::QUERY_VAR );
|
||||||
if ( empty( $routed ) || ! is_array( $routed ) ) {
|
if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
|
||||||
return $groupby;
|
return $groupby;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +285,46 @@ class TMDO_Post_Query_Router {
|
|||||||
return $groupby;
|
return $groupby;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop SQL_CALC_FOUND_ROWS from a routed search; keep its COUNT for later.
|
||||||
|
*
|
||||||
|
* @param string|null $request Full SELECT.
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function posts_request( ?string $request, \WP_Query $query ): string {
|
||||||
|
$request = (string) ( $request ?? '' );
|
||||||
|
if ( empty( $query->get( self::QUERY_VAR ) ) || false === strpos( $request, 'SQL_CALC_FOUND_ROWS' ) ) {
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
$request = preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
|
||||||
|
$matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
|
||||||
|
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer found rows from the cached COUNT of a routed search.
|
||||||
|
*
|
||||||
|
* @param string|null $sql Default 'SELECT FOUND_ROWS()'.
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function found_posts_query( ?string $sql, \WP_Query $query ): string {
|
||||||
|
$count_sql = $query->get( self::COUNT_VAR );
|
||||||
|
if ( empty( $count_sql ) ) {
|
||||||
|
return (string) ( $sql ?? '' );
|
||||||
|
}
|
||||||
|
$key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' );
|
||||||
|
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
|
||||||
|
if ( false === $total ) {
|
||||||
|
global $wpdb;
|
||||||
|
$total = (int) $wpdb->get_var( $count_sql );
|
||||||
|
wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS );
|
||||||
|
}
|
||||||
|
return 'SELECT ' . (int) $total;
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Helpers
|
// Helpers
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -242,6 +341,76 @@ class TMDO_Post_Query_Router {
|
|||||||
return TMDO_Mode_Manager::reads_from_flat( 'post' );
|
return TMDO_Mode_Manager::reads_from_flat( 'post' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget per-request copy checks (tests, or after `post-status-sync`).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function flush_copy_cache(): void {
|
||||||
|
self::$copies_usable = array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status / date copy route for a routed query, or [] when it does not apply.
|
||||||
|
*
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @param array<string, array<int, array>> $routed Routed clauses keyed by post type.
|
||||||
|
* @return array{post_type:string, group:string, statuses:string[], order:string}|array{}
|
||||||
|
*/
|
||||||
|
private static function copy_route( \WP_Query $query, array $routed ): array {
|
||||||
|
if ( 1 !== count( $routed ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$post_type = (string) array_key_first( $routed );
|
||||||
|
$groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) );
|
||||||
|
if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$statuses = $query->get( 'post_status' );
|
||||||
|
$statuses = array_values( array_filter( array_map( 'sanitize_key', is_array( $statuses ) ? $statuses : explode( ',', (string) $statuses ) ) ) );
|
||||||
|
if ( empty( $statuses ) || in_array( 'any', $statuses, true ) || ! self::copies_usable( $groups[0] ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'post_type' => $post_type,
|
||||||
|
'group' => $groups[0],
|
||||||
|
'statuses' => $statuses,
|
||||||
|
'order' => 'ASC' === strtoupper( (string) $query->get( 'order' ) ) ? 'ASC' : 'DESC',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the copy route applies to this post type's join.
|
||||||
|
*
|
||||||
|
* @param \WP_Query $query Current WP_Query.
|
||||||
|
* @param string $post_type Post type slug.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function copy_route_for( \WP_Query $query, string $post_type ): bool {
|
||||||
|
$copy = $query->get( self::COPY_VAR );
|
||||||
|
return ! empty( $copy ) && $copy['post_type'] === $post_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies are marked ready and no row lacks them (bulk writes that bypass
|
||||||
|
* the Hook Bus leave NULLs until `wp tmdo post-status-sync` runs again).
|
||||||
|
* One index probe per group per request.
|
||||||
|
*
|
||||||
|
* @param string $group Entity group name.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function copies_usable( string $group ): bool {
|
||||||
|
if ( ! isset( self::$copies_usable[ $group ] ) ) {
|
||||||
|
global $wpdb;
|
||||||
|
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
|
||||||
|
self::$copies_usable[ $group ] = class_exists( 'TMDO_Post_Status_Sync' )
|
||||||
|
&& 'ready' === TMDO_Post_Status_Sync::state( $group )
|
||||||
|
&& TMDO_Schema_Manager::table_exists( $table )
|
||||||
|
&& null === $wpdb->get_var( "SELECT 1 FROM `{$table}` WHERE `post_status` IS NULL LIMIT 1" );
|
||||||
|
}
|
||||||
|
return self::$copies_usable[ $group ];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a deterministic JOIN alias for a (post_type, group) pair.
|
* Generate a deterministic JOIN alias for a (post_type, group) pair.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ function flat_search( string $table, string $alias, string $conditions ): string
|
|||||||
. " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 0, 20";
|
. " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 0, 20";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** TMDO router shape with post_status / post_date copies (PR-029): INNER JOIN, no GROUP BY. */
|
||||||
|
function flat_copy_search( string $table, string $alias, string $conditions ): string {
|
||||||
|
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p INNER JOIN `" . P . "{$table}` AS `{$alias}` ON (p.ID = `{$alias}`.`post_id`)"
|
||||||
|
. " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} AND `{$alias}`.`post_status` IN ('publish') ORDER BY `{$alias}`.`post_date` DESC LIMIT 0, 20";
|
||||||
|
}
|
||||||
|
|
||||||
function random_ids( int $count, int $offset, int $max ): string {
|
function random_ids( int $count, int $offset, int $max ): string {
|
||||||
$ids = array();
|
$ids = array();
|
||||||
for ( $i = 0; $i < $count; $i++ ) {
|
for ( $i = 0; $i < $count; $i++ ) {
|
||||||
@@ -105,6 +111,10 @@ $scenarios = array(
|
|||||||
[ $lo, $hi ] = $price();
|
[ $lo, $hi ] = $price();
|
||||||
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' );
|
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' );
|
||||||
},
|
},
|
||||||
|
'entity+copy' => function () use ( $price ) {
|
||||||
|
[ $lo, $hi ] = $price();
|
||||||
|
return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' );
|
||||||
|
},
|
||||||
),
|
),
|
||||||
'2. 價格區間 + verified=1' => array(
|
'2. 價格區間 + verified=1' => array(
|
||||||
'native' => function () use ( $price ) {
|
'native' => function () use ( $price ) {
|
||||||
@@ -127,6 +137,10 @@ $scenarios = array(
|
|||||||
[ $lo, $hi ] = $price();
|
[ $lo, $hi ] = $price();
|
||||||
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' );
|
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' );
|
||||||
},
|
},
|
||||||
|
'entity+copy' => function () use ( $price ) {
|
||||||
|
[ $lo, $hi ] = $price();
|
||||||
|
return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' );
|
||||||
|
},
|
||||||
),
|
),
|
||||||
'3. 某 vendor 名下 listing' => array(
|
'3. 某 vendor 名下 listing' => array(
|
||||||
'post_parent' => fn() => array( 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p WHERE p.post_parent = " . mt_rand( 1, $vendors ) . " AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')) ORDER BY p.post_date DESC LIMIT 0, 20", 'SELECT FOUND_ROWS()' ),
|
'post_parent' => fn() => array( 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p WHERE p.post_parent = " . mt_rand( 1, $vendors ) . " AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')) ORDER BY p.post_date DESC LIMIT 0, 20", 'SELECT FOUND_ROWS()' ),
|
||||||
|
|||||||
+4
-1
@@ -230,8 +230,10 @@ if ( ! function_exists( 'esc_sql' ) ) {
|
|||||||
return addslashes( $s );
|
return addslashes( $s );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } }
|
||||||
|
if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } }
|
||||||
if ( ! function_exists( '_doing_it_wrong' ) ) {
|
if ( ! function_exists( '_doing_it_wrong' ) ) {
|
||||||
function _doing_it_wrong( string $fn, string $msg, string $ver ): void {}
|
function _doing_it_wrong( string $fn, string $msg, string $ver ): void { $GLOBALS['_doing_it_wrong_calls'][] = array( $fn, $msg ); }
|
||||||
}
|
}
|
||||||
if ( ! function_exists( 'get_option' ) ) {
|
if ( ! function_exists( 'get_option' ) ) {
|
||||||
function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; }
|
function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; }
|
||||||
@@ -644,6 +646,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p
|
|||||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
|
||||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
|
||||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
|
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
|
||||||
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
|
||||||
|
|
||||||
// Options Manager.
|
// Options Manager.
|
||||||
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
|
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite indexes declared on a group reach its existing flat table.
|
||||||
|
*
|
||||||
|
* The table already exists with data (its schema hash is unchanged), so the
|
||||||
|
* index is added in place rather than through a table rebuild.
|
||||||
|
*/
|
||||||
|
class GroupCompositeIndexTest extends TestCase {
|
||||||
|
|
||||||
|
private const TABLE = 'wp_itest_wpdo_post_ci_core';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, ci_flag BIGINT(20) DEFAULT NULL, ci_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' );
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group(
|
||||||
|
'post',
|
||||||
|
'ci_core',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'ci_flag', 'type' => 'integer' ),
|
||||||
|
array( 'key' => 'ci_price', 'type' => 'decimal' ),
|
||||||
|
),
|
||||||
|
array( 'indexes' => array( 'flag_price' => array( 'ci_flag', 'ci_price' ) ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string[]> index name → ordered columns */
|
||||||
|
private function indexes(): array {
|
||||||
|
global $wpdb;
|
||||||
|
$out = array();
|
||||||
|
foreach ( (array) $wpdb->get_results( 'SHOW INDEX FROM `' . self::TABLE . '`', ARRAY_A ) as $row ) {
|
||||||
|
$out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_missing_composite_index_is_added_to_the_existing_table(): void {
|
||||||
|
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'ci_flag', 'ci_price' ), $this->indexes()['cidx_flag_price'] ?? null );
|
||||||
|
global $wpdb;
|
||||||
|
$this->assertSame( '2', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' ), 'data untouched' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_applied_definition_is_remembered_so_later_requests_skip_the_check(): void {
|
||||||
|
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` DROP INDEX `cidx_flag_price`' );
|
||||||
|
|
||||||
|
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'same definition → no SHOW INDEX / ALTER' );
|
||||||
|
$this->assertArrayHasKey( 'post/ci_core', get_option( TMDO_Schema_Manager::INDEXES_OPTION ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_group_without_indexes_is_left_alone(): void {
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'ci_core', array( array( 'key' => 'ci_flag', 'type' => 'integer' ) ) );
|
||||||
|
|
||||||
|
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'PRIMARY', 'uk_entity' ), array_keys( $this->indexes() ) );
|
||||||
|
$this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retiring one entity group: write its flat values back to native meta, then
|
||||||
|
* forget it so orphan restore never routes its keys to the flat table again.
|
||||||
|
*
|
||||||
|
* Fixture (user, aeav_only): group rt_a (token, ips) and rt_b (other).
|
||||||
|
* rt_a user 1: token 't1', ips '["1.1.1.1"]' user 2: token 't2', ips NULL
|
||||||
|
* rt_b user 1: other 'o1'
|
||||||
|
*/
|
||||||
|
class NativeRestorerRetireTest extends TestCase {
|
||||||
|
|
||||||
|
private const A = 'wp_itest_wpdo_user_rt_a';
|
||||||
|
private const B = 'wp_itest_wpdo_user_rt_b';
|
||||||
|
private const META = 'wp_itest_wpdo_registry_meta';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
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-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-user.php',
|
||||||
|
'includes/migration/class-tmdo-native-restorer.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $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), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::A . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, token VARCHAR(255) DEFAULT NULL, ips LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::B . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, other VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::A . "` (user_id, token, ips) VALUES (1, 't1', '[\"1.1.1.1\"]'), (2, 't2', NULL)" );
|
||||||
|
$wpdb->insert( self::B, array( 'user_id' => 1, 'other' => 'o1' ) );
|
||||||
|
$wpdb->query( 'UPDATE `' . self::A . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||||
|
$wpdb->query( 'UPDATE `' . self::B . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'aeav_only' ) );
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
$GLOBALS['_wp_transients'] = array();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
|
||||||
|
self::register( true );
|
||||||
|
$wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) );
|
||||||
|
$wpdb->insert(
|
||||||
|
self::META,
|
||||||
|
array(
|
||||||
|
'entity_type' => 'user',
|
||||||
|
'group_name' => 'rt_a',
|
||||||
|
'schema_hash' => 'itest',
|
||||||
|
'field_definitions' => wp_json_encode( WPDO_Entity_Registry::get_group_fields( 'user', 'rt_a' ) ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
TMDO_Mode_Manager::reset_cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh registry; rt_b always registered, rt_a only while its provider is active. */
|
||||||
|
private static function register( bool $with_a ): void {
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||||
|
if ( $with_a ) {
|
||||||
|
WPDO_Entity_Registry::register_group(
|
||||||
|
'user',
|
||||||
|
'rt_a',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'token', 'type' => 'text' ),
|
||||||
|
array( 'key' => 'ips', 'type' => 'textarea' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
WPDO_Entity_Registry::register_group( 'user', 'rt_b', array( array( 'key' => 'other', 'type' => 'text' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( (array) $wpdb->get_col( "SHOW TABLES LIKE 'wp\\_itest\\_wpdo\\_user\\_rt\\_%'" ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" );
|
||||||
|
$wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function native( int $user_id, string $key ): array {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$wpdb->usermeta}` WHERE user_id = %d AND meta_key = %s", $user_id, $key ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_group_option_writes_back_only_that_group(): void {
|
||||||
|
$report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'rt_a' ), array_keys( $report['groups'] ) );
|
||||||
|
$this->assertSame( array( 't1' ), $this->native( 1, 'token' ) );
|
||||||
|
$this->assertSame( array( '["1.1.1.1"]' ), $this->native( 1, 'ips' ) );
|
||||||
|
$this->assertSame( array(), $this->native( 1, 'other' ), 'rt_b untouched' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retire_refuses_a_group_that_is_still_registered(): void {
|
||||||
|
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||||
|
|
||||||
|
$this->assertSame( 'registered', TMDO_Native_Restorer::retire( 'user', 'rt_a' )['status'] );
|
||||||
|
global $wpdb;
|
||||||
|
$this->assertSame( self::A, $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retire_refuses_when_native_meta_differs_from_the_flat_table(): void {
|
||||||
|
self::register( false );
|
||||||
|
|
||||||
|
$result = TMDO_Native_Restorer::retire( 'user', 'rt_a' );
|
||||||
|
|
||||||
|
$this->assertSame( 'mismatch', $result['status'] );
|
||||||
|
$this->assertSame( 3, $result['mismatches'], 't1, ["1.1.1.1"], t2 are not native yet' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_empty_string_values_count_as_written_back(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'UPDATE `' . self::A . "` SET ips = '' WHERE user_id = 2" );
|
||||||
|
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||||
|
self::register( false );
|
||||||
|
|
||||||
|
$this->assertSame( 'retired', TMDO_Native_Restorer::retire( 'user', 'rt_a' )['status'], "wpdb::get_col() reads '' as null" );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retire_forgets_the_group_and_keeps_its_table_as_a_backup(): void {
|
||||||
|
global $wpdb;
|
||||||
|
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
|
||||||
|
self::register( false );
|
||||||
|
|
||||||
|
$result = TMDO_Native_Restorer::retire( 'user', 'rt_a' );
|
||||||
|
|
||||||
|
$this->assertSame( 'retired', $result['status'] );
|
||||||
|
$this->assertMatchesRegularExpression( '/^' . self::A . '_retired_\d{8}$/', $result['archived_table'] );
|
||||||
|
$this->assertSame( $result['archived_table'], $wpdb->get_var( "SHOW TABLES LIKE '" . $result['archived_table'] . "'" ) );
|
||||||
|
$this->assertNull( $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) );
|
||||||
|
$this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::META . "` WHERE entity_type = 'user' AND group_name = 'rt_a'" ) );
|
||||||
|
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups(), 'orphan restore no longer routes the keys to the flat table' );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routed searches filter status and sort by date on the flat table's
|
||||||
|
* post_status / post_date copies (O-1), when those copies are trustworthy.
|
||||||
|
*/
|
||||||
|
class PostQueryRouterCopyTest extends TestCase {
|
||||||
|
|
||||||
|
private const TABLE = 'wp_itest_wpdo_post_rc_core';
|
||||||
|
private const ALIAS = '`wpdoflat_rc_item_rc_core`';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
'includes/integrations/class-tmdo-post-fields.php',
|
||||||
|
'includes/engine/class-tmdo-post-status-sync.php',
|
||||||
|
'includes/query/class-tmdo-post-query-router.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date (post_status, post_date) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::TABLE . "` (post_id, rc_price, post_status, post_date) VALUES (1, 10, 'publish', '2026-01-01 00:00:00')" );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'ready' ) );
|
||||||
|
$GLOBALS['_wp_is_admin'] = false;
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$mode->setAccessible( true );
|
||||||
|
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
TMDO_Post_Query_Router::flush_copy_cache();
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'rc_core', array( array( 'key' => 'rc_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'rc_item' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$mode->setAccessible( true );
|
||||||
|
$mode->setValue( null, null );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{join:string, where:string, orderby:string, groupby:string} */
|
||||||
|
private function sql( array $vars ): array {
|
||||||
|
$query = new WP_Query();
|
||||||
|
foreach ( array_merge(
|
||||||
|
array(
|
||||||
|
'post_type' => 'rc_item',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN' ) ),
|
||||||
|
),
|
||||||
|
$vars
|
||||||
|
) as $k => $v ) {
|
||||||
|
$query->set( $k, $v );
|
||||||
|
}
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
$router->pre_get_posts( $query );
|
||||||
|
return array(
|
||||||
|
'join' => $router->posts_join( '', $query ),
|
||||||
|
'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ),
|
||||||
|
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
|
||||||
|
'groupby' => $router->posts_groupby( '', $query ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assert_legacy_shape( array $sql ): void {
|
||||||
|
$this->assertStringContainsString( 'LEFT JOIN', $sql['join'] );
|
||||||
|
$this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] );
|
||||||
|
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
|
||||||
|
$this->assertSame( '`wp_itest_posts`.`ID`', $sql['groupby'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void {
|
||||||
|
$sql = $this->sql( array( 'order' => 'ASC' ) );
|
||||||
|
|
||||||
|
$this->assertStringContainsString( 'INNER JOIN', $sql['join'] );
|
||||||
|
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] );
|
||||||
|
$this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' );
|
||||||
|
$this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] );
|
||||||
|
$this->assertSame( '', $sql['groupby'], 'one row per post: no GROUP BY, so LIMIT can stop early' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_several_explicit_statuses_are_kept(): void {
|
||||||
|
$sql = $this->sql( array( 'post_status' => array( 'publish', 'private' ), 'orderby' => 'date' ) );
|
||||||
|
|
||||||
|
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish','private')", $sql['where'] );
|
||||||
|
$this->assertSame( self::ALIAS . '.`post_date` DESC', $sql['orderby'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_copies_not_marked_ready_keep_the_legacy_shape(): void {
|
||||||
|
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'columns' ) );
|
||||||
|
|
||||||
|
$this->assert_legacy_shape( $this->sql( array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rows_with_missing_copies_keep_the_legacy_shape(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, rc_price) VALUES (2, 12)' );
|
||||||
|
|
||||||
|
$this->assert_legacy_shape( $this->sql( array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_other_orderings_or_implicit_statuses_keep_the_legacy_shape(): void {
|
||||||
|
$this->assert_legacy_shape( $this->sql( array( 'orderby' => 'title' ) ) );
|
||||||
|
$this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) );
|
||||||
|
$this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routed searches drop SQL_CALC_FOUND_ROWS; the total comes from a COUNT
|
||||||
|
* cached until posts' last_changed moves (O-4).
|
||||||
|
*
|
||||||
|
* Three fr_item posts, prices 10 / 20 / 30.
|
||||||
|
*/
|
||||||
|
class PostQueryRouterFoundRowsTest extends TestCase {
|
||||||
|
|
||||||
|
private const TABLE = 'wp_itest_wpdo_post_fr_core';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
foreach ( array(
|
||||||
|
'includes/adapters/interface-entity-adapter.php',
|
||||||
|
'includes/engine/class-tmdo-type-caster.php',
|
||||||
|
'includes/engine/class-tmdo-entity-registry.php',
|
||||||
|
'includes/engine/class-tmdo-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-schema-manager.php',
|
||||||
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
'includes/integrations/class-tmdo-post-fields.php',
|
||||||
|
'includes/engine/class-tmdo-hook-bus.php',
|
||||||
|
'includes/engine/class-tmdo-post-status-sync.php',
|
||||||
|
'includes/query/class-tmdo-post-query-router.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, fr_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type) VALUES (1, 'fr_item'), (2, 'fr_item'), (3, 'fr_item')" );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, fr_price) VALUES (1, 10), (2, 20), (3, 30)' );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
$GLOBALS['_wp_last_changed'] = array();
|
||||||
|
$GLOBALS['_wp_is_admin'] = false;
|
||||||
|
$GLOBALS['_wp_current_filter'] = '';
|
||||||
|
$GLOBALS['_wp_post_types'] = array( 1 => 'fr_item', 2 => 'fr_item', 3 => 'fr_item' );
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$mode->setAccessible( true );
|
||||||
|
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
TMDO_Post_Query_Router::flush_copy_cache();
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'fr_core', array( array( 'key' => 'fr_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'fr_item' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$mode->setAccessible( true );
|
||||||
|
$mode->setValue( null, null );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( $wpdb->posts, self::TABLE ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A routed query plus the SQL WordPress would build for it (page size 1). */
|
||||||
|
private function routed(): array {
|
||||||
|
global $wpdb;
|
||||||
|
$query = new WP_Query();
|
||||||
|
$query->set( 'post_type', 'fr_item' );
|
||||||
|
$query->set( 'post_status', 'publish' );
|
||||||
|
$query->set( 'meta_query', array( array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=' ) ) );
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
$router->pre_get_posts( $query );
|
||||||
|
|
||||||
|
$request = "SELECT SQL_CALC_FOUND_ROWS {$wpdb->posts}.ID FROM {$wpdb->posts} " . $router->posts_join( '', $query )
|
||||||
|
. " WHERE 1=1 AND {$wpdb->posts}.post_type = 'fr_item' AND (({$wpdb->posts}.post_status = 'publish'))" . $router->posts_where( '', $query )
|
||||||
|
. " GROUP BY {$wpdb->posts}.ID ORDER BY {$wpdb->posts}.post_date DESC LIMIT 0, 1";
|
||||||
|
return array( $router, $query, $request );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_routed_request_drops_sql_calc_found_rows(): void {
|
||||||
|
list( $router, $query, $request ) = $this->routed();
|
||||||
|
|
||||||
|
$sql = $router->posts_request( $request, $query );
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $sql );
|
||||||
|
$this->assertStringContainsString( 'LIMIT 0, 1', $sql );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unrouted_request_is_left_alone(): void {
|
||||||
|
$router = new TMDO_Post_Query_Router();
|
||||||
|
$request = 'SELECT SQL_CALC_FOUND_ROWS wp_itest_posts.ID FROM wp_itest_posts WHERE 1=1 LIMIT 0, 10';
|
||||||
|
|
||||||
|
$this->assertSame( $request, $router->posts_request( $request, new WP_Query() ) );
|
||||||
|
$this->assertSame( 'SELECT FOUND_ROWS()', $router->found_posts_query( 'SELECT FOUND_ROWS()', new WP_Query() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_is_counted_once_and_served_from_cache_until_posts_change(): void {
|
||||||
|
global $wpdb;
|
||||||
|
list( $router, $query, $request ) = $this->routed();
|
||||||
|
$router->posts_request( $request, $query );
|
||||||
|
|
||||||
|
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) );
|
||||||
|
|
||||||
|
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 2' );
|
||||||
|
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'cached' );
|
||||||
|
|
||||||
|
wp_cache_set_posts_last_changed();
|
||||||
|
$this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_flat_writes_and_deletes_move_posts_last_changed(): void {
|
||||||
|
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
||||||
|
$before = wp_cache_get_last_changed( 'posts' );
|
||||||
|
WPDO_Hook_Bus::intercept_update( null, 1, 'fr_price', '11', '' );
|
||||||
|
$after_write = wp_cache_get_last_changed( 'posts' );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_current_filter'] = 'delete_post_metadata';
|
||||||
|
WPDO_Hook_Bus::intercept_delete( null, 1, 'fr_price', '', false );
|
||||||
|
|
||||||
|
$this->assertNotSame( $before, $after_write );
|
||||||
|
$this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* post_status / post_date copies on post entity tables (O-1 schema).
|
||||||
|
*
|
||||||
|
* Listing 1 (publish) has rows in both groups, draft 2 only in ps_a.
|
||||||
|
*/
|
||||||
|
class PostStatusSyncTest extends TestCase {
|
||||||
|
|
||||||
|
private const A = 'wp_itest_wpdo_post_ps_a';
|
||||||
|
private const B = 'wp_itest_wpdo_post_ps_b';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void {
|
||||||
|
$base = WPDO_PLUGIN_DIR;
|
||||||
|
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-mode-manager.php',
|
||||||
|
'includes/engine/class-tmdo-cache-orchestrator.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-post.php',
|
||||||
|
'includes/engine/class-tmdo-hook-bus.php',
|
||||||
|
'includes/engine/class-tmdo-post-status-sync.php',
|
||||||
|
) as $file ) {
|
||||||
|
require_once $base . $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
global $wpdb;
|
||||||
|
self::drop();
|
||||||
|
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci" );
|
||||||
|
foreach ( array( self::A, self::B ) as $table ) {
|
||||||
|
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, k VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" );
|
||||||
|
}
|
||||||
|
$wpdb->query( "INSERT INTO `{$wpdb->posts}` VALUES (1, 'hp_listing', 'publish', '2026-01-01 10:00:00'), (2, 'hp_listing', 'draft', '2026-02-01 10:00:00'), (3, 'hp_listing', 'pending', '2026-03-01 10:00:00')" );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::A . "` (post_id, k) VALUES (1, 'a1'), (2, 'a2')" );
|
||||||
|
$wpdb->query( 'INSERT INTO `' . self::B . "` (post_id, k) VALUES (1, 'b1')" );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_options'] = array();
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
$GLOBALS['_wp_posts'] = array();
|
||||||
|
$GLOBALS['_wp_current_filter'] = '';
|
||||||
|
|
||||||
|
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, array() );
|
||||||
|
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'ps_a', array( array( 'key' => 'k', 'type' => 'text' ) ) );
|
||||||
|
WPDO_Entity_Registry::register_group( 'post', 'ps_b', array( array( 'key' => 'k2', 'type' => 'text' ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
self::drop();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$ref->setAccessible( true );
|
||||||
|
$ref->setValue( null, null );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function drop(): void {
|
||||||
|
global $wpdb;
|
||||||
|
foreach ( array( $wpdb->posts, self::A, self::B ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array{0:?string, 1:?string}> post_id → [status, date] */
|
||||||
|
private function copies( string $table ): array {
|
||||||
|
global $wpdb;
|
||||||
|
$out = array();
|
||||||
|
foreach ( (array) $wpdb->get_results( "SELECT post_id, post_status, post_date FROM `{$table}` ORDER BY post_id", ARRAY_A ) as $row ) {
|
||||||
|
$out[ (int) $row['post_id'] ] = array( $row['post_status'], $row['post_date'] );
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function has_index( string $table ): bool {
|
||||||
|
global $wpdb;
|
||||||
|
return in_array( 'idx_post_status_date', array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ), true );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void {
|
||||||
|
$report = TMDO_Post_Status_Sync::run( false, 1 );
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
array(
|
||||||
|
1 => array( 'publish', '2026-01-01 10:00:00' ),
|
||||||
|
2 => array( 'draft', '2026-02-01 10:00:00' ),
|
||||||
|
),
|
||||||
|
$this->copies( self::A )
|
||||||
|
);
|
||||||
|
$this->assertSame( array( 1 => array( 'publish', '2026-01-01 10:00:00' ) ), $this->copies( self::B ) );
|
||||||
|
$this->assertTrue( $this->has_index( self::A ) );
|
||||||
|
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
||||||
|
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_b' ) );
|
||||||
|
$this->assertSame( 0, TMDO_Post_Status_Sync::mismatches( 'ps_a' ) );
|
||||||
|
$this->assertSame( 2, $report['ps_a']['mismatched'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_copies_use_the_posts_collation_so_they_compare_with_posts(): void {
|
||||||
|
global $wpdb;
|
||||||
|
TMDO_Post_Status_Sync::run();
|
||||||
|
|
||||||
|
$column = $wpdb->get_row( 'SHOW FULL COLUMNS FROM `' . self::A . "` LIKE 'post_status'", ARRAY_A );
|
||||||
|
$this->assertSame( 'utf8mb4_unicode_520_ci', $column['Collation'] );
|
||||||
|
$wpdb->query( 'UPDATE `' . self::A . "` SET post_status = 'stale' WHERE post_id = 2" );
|
||||||
|
$this->assertSame( 1, TMDO_Post_Status_Sync::mismatches( 'ps_a' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dry_run_only_counts(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$report = TMDO_Post_Status_Sync::run( true );
|
||||||
|
|
||||||
|
$this->assertSame( 2, $report['ps_a']['mismatched'] );
|
||||||
|
$this->assertSame( 1, $report['ps_b']['mismatched'] );
|
||||||
|
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
|
||||||
|
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_status_transition_updates_the_post_in_every_group_table(): void {
|
||||||
|
TMDO_Post_Status_Sync::run();
|
||||||
|
|
||||||
|
TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::A )[1] );
|
||||||
|
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::B )[1] );
|
||||||
|
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2], 'other posts untouched' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_transition_leaves_tables_without_the_columns_alone(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->last_error = '';
|
||||||
|
|
||||||
|
TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) );
|
||||||
|
|
||||||
|
$this->assertSame( '', (string) $wpdb->last_error );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_new_empty_table_gets_the_columns_at_once_but_a_populated_one_waits_for_the_cli(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query( 'TRUNCATE TABLE `' . self::B . '`' );
|
||||||
|
|
||||||
|
TMDO_Post_Status_Sync::on_schema_updated( 'post', 'ps_b', self::B );
|
||||||
|
TMDO_Post_Status_Sync::on_schema_updated( 'post', 'ps_a', self::A );
|
||||||
|
|
||||||
|
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_b' ) );
|
||||||
|
$this->assertTrue( $this->has_index( self::B ) );
|
||||||
|
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_a' ) );
|
||||||
|
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_flat_write_that_creates_a_row_fills_the_copies(): void {
|
||||||
|
TMDO_Post_Status_Sync::run();
|
||||||
|
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
|
||||||
|
$mode->setAccessible( true );
|
||||||
|
$mode->setValue( null, array( 'post' => 'dual_write', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' );
|
||||||
|
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
|
||||||
|
|
||||||
|
WPDO_Hook_Bus::intercept_update( null, 3, 'k', 'a3', '' );
|
||||||
|
WPDO_Hook_Bus::intercept_update( null, 1, 'k', 'a1b', '' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'pending', '2026-03-01 10:00:00' ), $this->copies( self::A )[3] );
|
||||||
|
$this->assertSame( array( 'publish', '2026-01-01 10:00:00' ), $this->copies( self::A )[1], 'existing row keeps its synced copies' );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,7 +129,8 @@ $wpdb = new class( $_mysqli ) {
|
|||||||
public function get_var( string $sql ): ?string {
|
public function get_var( string $sql ): ?string {
|
||||||
$result = $this->db->query( $sql );
|
$result = $this->db->query( $sql );
|
||||||
if ( ! $result || ! ( $row = $result->fetch_row() ) ) { return null; }
|
if ( ! $result || ! ( $row = $result->fetch_row() ) ) { return null; }
|
||||||
return $row[0] !== null ? (string) $row[0] : null;
|
// Like wpdb::get_var(): an empty string reads as null.
|
||||||
|
return null !== $row[0] && '' !== $row[0] ? (string) $row[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get_row( string $sql, $output = 'OBJECT' ) {
|
public function get_row( string $sql, $output = 'OBJECT' ) {
|
||||||
@@ -152,7 +153,8 @@ $wpdb = new class( $_mysqli ) {
|
|||||||
$result = $this->db->query( $sql );
|
$result = $this->db->query( $sql );
|
||||||
if ( ! $result ) { return []; }
|
if ( ! $result ) { return []; }
|
||||||
$values = [];
|
$values = [];
|
||||||
while ( $row = $result->fetch_row() ) { $values[] = $row[ $col_index ] ?? null; }
|
// Like wpdb::get_col() (per-row get_var()): an empty string reads as null.
|
||||||
|
while ( $row = $result->fetch_row() ) { $values[] = isset( $row[ $col_index ] ) && '' !== $row[ $col_index ] ? $row[ $col_index ] : null; }
|
||||||
return $values;
|
return $values;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,6 +331,7 @@ if ( ! function_exists( 'get_term_meta' ) ) { function get_term_meta( int $tid,
|
|||||||
if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } }
|
if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } }
|
||||||
if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } }
|
if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } }
|
||||||
if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } }
|
if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } }
|
||||||
|
if ( ! function_exists( 'get_post' ) ) { function get_post( $post_id ) { return $GLOBALS['_wp_posts'][ (int) $post_id ] ?? null; } }
|
||||||
if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
|
if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
|
||||||
if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
|
if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
|
||||||
if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
|
if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
|
||||||
@@ -407,6 +410,8 @@ if ( ! function_exists( 'wp_mkdir_p' ) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$GLOBALS['_wp_cache'] = [];
|
$GLOBALS['_wp_cache'] = [];
|
||||||
|
if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } }
|
||||||
|
if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } }
|
||||||
if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } }
|
if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } }
|
||||||
if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
|
if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
|
||||||
if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; } }
|
if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; } }
|
||||||
@@ -582,6 +587,7 @@ require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.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';
|
||||||
|
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.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';
|
||||||
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
|
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ class DeprecatedAddonsNoticeTest extends TestCase {
|
|||||||
$this->assertStringContainsString( 'PARTNER-REGISTRATION.md', $html );
|
$this->assertStringContainsString( 'PARTNER-REGISTRATION.md', $html );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_sso_hub_and_spoke_addons_are_deprecated_without_replacement(): void {
|
||||||
|
$this->activate( 'hub', 'sso' );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'hub', 'sso' ), TMDO_Deprecated_Addons_Notice::active() );
|
||||||
|
ob_start();
|
||||||
|
TMDO_Deprecated_Addons_Notice::render();
|
||||||
|
$html = (string) ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertStringContainsString( '2meet-data-optimizer-sso-addon', $html );
|
||||||
|
$this->assertStringContainsString( '不需替代', $html );
|
||||||
|
$this->assertStringContainsString( 'bridge-restore-native user --group=sso --retire', $html );
|
||||||
|
}
|
||||||
|
|
||||||
public function test_no_notice_when_none_active_or_user_cannot_manage(): void {
|
public function test_no_notice_when_none_active_or_user_cannot_manage(): void {
|
||||||
ob_start();
|
ob_start();
|
||||||
TMDO_Deprecated_Addons_Notice::render();
|
TMDO_Deprecated_Addons_Notice::render();
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite indexes declared on entity groups.
|
||||||
|
*
|
||||||
|
* Declared through register_group()'s $args like post_types, so a group's
|
||||||
|
* field definition — and its persisted schema hash — never changes when an
|
||||||
|
* index is added.
|
||||||
|
*/
|
||||||
|
class EntityRegistryIndexesTest extends TestCase {
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fields(): array {
|
||||||
|
return array(
|
||||||
|
array( 'key' => 'x_flag', 'type' => 'integer' ),
|
||||||
|
array( 'key' => 'x_price', 'type' => 'decimal' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_declared_indexes_are_kept_per_group(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'flag_price' => array( 'x_flag', 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
|
||||||
|
$this->assertSame( array(), TMDO_Entity_Registry::get_group_indexes( 'post', 'other' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_indexes_on_unknown_columns_are_dropped(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'bad' => array( 'x_flag', 'nope' ), 'ok' => array( 'x_price' ) ) ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'ok' => array( 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_indexes_leave_the_field_definition_and_hash_alone(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'plain', $this->fields() );
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'indexed', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) );
|
||||||
|
|
||||||
|
$plain = TMDO_Schema_Manager::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( 'post', 'plain' ) );
|
||||||
|
$indexed = TMDO_Schema_Manager::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( 'post', 'indexed' ) );
|
||||||
|
$this->assertSame( $plain, $indexed );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_init_clears_indexes(): void {
|
||||||
|
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'ok' => array( 'x_price' ) ) ) );
|
||||||
|
TMDO_Entity_Registry::init();
|
||||||
|
|
||||||
|
$this->assertSame( array(), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,12 +39,18 @@ class MemberFieldsRegistrationTest extends TestCase {
|
|||||||
public function test_all_four_groups_are_registered(): void {
|
public function test_all_four_groups_are_registered(): void {
|
||||||
WPDO_Member_Fields::register_entity_fields();
|
WPDO_Member_Fields::register_entity_fields();
|
||||||
|
|
||||||
foreach ( array( 'membership', 'activity', 'profile', 'sso' ) as $group ) {
|
foreach ( array( 'membership', 'activity', 'profile' ) as $group ) {
|
||||||
$fields = WPDO_Entity_Registry::get_group_fields( 'user', $group );
|
$fields = WPDO_Entity_Registry::get_group_fields( 'user', $group );
|
||||||
$this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." );
|
$this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_core_leaves_the_sso_group_to_the_sso_addon(): void {
|
||||||
|
WPDO_Member_Fields::register_entity_fields();
|
||||||
|
|
||||||
|
$this->assertSame( array(), WPDO_Entity_Registry::get_group_fields( 'user', 'sso' ) );
|
||||||
|
}
|
||||||
|
|
||||||
public function test_core_registers_no_hp_user_group(): void {
|
public function test_core_registers_no_hp_user_group(): void {
|
||||||
WPDO_Member_Fields::register_entity_fields();
|
WPDO_Member_Fields::register_entity_fields();
|
||||||
|
|
||||||
@@ -148,41 +154,6 @@ class MemberFieldsRegistrationTest extends TestCase {
|
|||||||
$this->assertTrue( (bool) $field['fulltext'] );
|
$this->assertTrue( (bool) $field['fulltext'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── sso group ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function test_sso_group_has_eight_fields(): void {
|
|
||||||
WPDO_Member_Fields::register_entity_fields();
|
|
||||||
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
|
|
||||||
// v3.4.4: known_login_ips registered (was leaking to wp_usermeta via set_entity).
|
|
||||||
$this->assertCount( 8, $fields );
|
|
||||||
$this->assertNotNull( $this->find_field( $fields, 'known_login_ips' ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_hub_global_user_id_is_searchable(): void {
|
|
||||||
WPDO_Member_Fields::register_entity_fields();
|
|
||||||
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
|
|
||||||
$field = $this->find_field( $fields, 'hub_global_user_id' );
|
|
||||||
|
|
||||||
$this->assertTrue( (bool) $field['searchable'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_token_expires_at_is_datetime_searchable(): void {
|
|
||||||
WPDO_Member_Fields::register_entity_fields();
|
|
||||||
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
|
|
||||||
$field = $this->find_field( $fields, 'token_expires_at' );
|
|
||||||
|
|
||||||
$this->assertSame( 'datetime', $field['type'] );
|
|
||||||
$this->assertTrue( (bool) $field['searchable'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_refresh_token_enc_is_textarea(): void {
|
|
||||||
WPDO_Member_Fields::register_entity_fields();
|
|
||||||
$fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
|
|
||||||
$field = $this->find_field( $fields, 'refresh_token_enc' );
|
|
||||||
|
|
||||||
$this->assertSame( 'textarea', $field['type'] );
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── register() hooks add_action ──────────────────────────────────────────
|
// ── register() hooks add_action ──────────────────────────────────────────
|
||||||
|
|
||||||
public function test_register_hooks_wpdo_register_entity_fields(): void {
|
public function test_register_hooks_wpdo_register_entity_fields(): void {
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ class MissingIntegrationAddonsTest extends TestCase {
|
|||||||
$this->assertSame( array( 'WooCommerce' => '2meet-data-optimizer-woocommerce-addon' ), TMDO_Compatibility::missing_integration_addons() );
|
$this->assertSame( array( 'WooCommerce' => '2meet-data-optimizer-woocommerce-addon' ), TMDO_Compatibility::missing_integration_addons() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @runInSeparateProcess
|
||||||
|
* @preserveGlobalState disabled
|
||||||
|
*/
|
||||||
|
public function test_hub_and_spoke_need_no_addon(): void {
|
||||||
|
eval( 'final class TMHC_Plugin {}' );
|
||||||
|
eval( 'final class TMSO_Plugin {}' );
|
||||||
|
|
||||||
|
$this->assertSame( array(), TMDO_Compatibility::missing_integration_addons() );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @runInSeparateProcess
|
* @runInSeparateProcess
|
||||||
* @preserveGlobalState disabled
|
* @preserveGlobalState disabled
|
||||||
|
|||||||
@@ -18,6 +18,30 @@ class SchemaRegistryTest extends TestCase {
|
|||||||
$instance->setValue( null, null );
|
$instance->setValue( null, null );
|
||||||
|
|
||||||
$this->registry = WPDO_Schema_Registry::instance();
|
$this->registry = WPDO_Schema_Registry::instance();
|
||||||
|
$GLOBALS['_doing_it_wrong_calls'] = array();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hot zone deprecation (ADR-003) ──────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_hot_registration_is_flagged_once_per_provider(): void {
|
||||||
|
foreach ( array( 'p1', 'p1', 'p2' ) as $i => $provider ) {
|
||||||
|
$this->registry->register( $provider, array( 'post_type' => 'x', 'meta_key' => "k{$i}", 'zone' => 'hot' ) );
|
||||||
|
}
|
||||||
|
$this->registry->register( 'p1', array( 'post_type' => 'x', 'meta_key' => 'k9' ) ); // zone defaults to hot.
|
||||||
|
|
||||||
|
$calls = $GLOBALS['_doing_it_wrong_calls'];
|
||||||
|
$this->assertCount( 2, $calls );
|
||||||
|
$this->assertStringContainsString( 'p1', $calls[0][1] );
|
||||||
|
$this->assertStringContainsString( 'p2', $calls[1][1] );
|
||||||
|
$this->assertSame( 'hot', $this->registry->get_field_zone( 'x', 'k9' ), 'still registered: frozen, not rejected' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_other_zones_are_not_flagged(): void {
|
||||||
|
$this->registry->register( 'p', array( 'post_type' => 'x', 'meta_key' => 'c', 'zone' => 'cold' ) );
|
||||||
|
$this->registry->register( 'p', array( 'post_type' => 'x', 'meta_key' => 'w', 'zone' => 'warm' ) );
|
||||||
|
$this->registry->register( 'p', array( 'post_type' => 'x', 'meta_key' => 'a', 'zone' => 'archive' ) );
|
||||||
|
|
||||||
|
$this->assertSame( array(), $GLOBALS['_doing_it_wrong_calls'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── register() ──────────────────────────────────────────────────────────
|
// ── register() ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user