Compare commits

30 Commits

Author SHA1 Message Date
wpdev 336664473b fix(2meet-data-optimizer): keep registered fields out of the public listings REST endpoints
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 15s
Tests / PHP Lint (pull_request) Successful in 17s
Tests / Unit Tests (pull_request) Successful in 51s
Tests / Integration Tests (pull_request) Successful in 1m22s
Tests / PHPCS (pull_request) Successful in 1m12s
Tests / PHPStan (pull_request) Successful in 1m10s
- show_in_rest defaulted to true, so GET /wpdo/v1/listings and /listings/{id} (public by design) returned every registered hot / cold field to anonymous visitors — e.g. infocards tmeetic_ig_access_token_enc / _token_expiry / _user_id and WooCommerce _purchase_note (confirmed on dev36)
- Fields are private unless registered with show_in_rest => true; protected (underscore) and credential-like keys (token, secret, password, api key, private key, credential, salt, nonce) are never exposed
- Zone A list path no longer returns whole rows when nothing is public, and filters / orderby accept public columns only
- Version 1.1.1; CHANGELOG, readme
- Verify: unit 690 (+3), integration 525 (+3 private-column tests, RED on 1.1.0), live suite rest 2/2 (RED on 1.1.0), all live 49/49, phpcs 0, stan OK
2026-09-26 22:22:40 +08:00
wpdev abbdb39acc Merge pull request 'hardening 2.1: 2meet-data-optimizer 1.1.0 (search-index mode, staged rollout)' (#4) from hardening/2.1 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 16s
Tests / PHP Lint (push) Successful in 15s
Tests / Unit Tests (push) Successful in 1m0s
Tests / PHPCS (push) Successful in 35s
Tests / Integration Tests (push) Successful in 1m36s
Tests / PHPStan (push) Successful in 54s
2026-09-26 21:22:48 +08:00
wpdev e8731c5119 docs(2meet-data-optimizer): add PR-057b and PR-062c to the 1.1.0 notes
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 18s
Tests / PHP Lint (pull_request) Successful in 59s
Tests / PHPCS (pull_request) Successful in 30s
Tests / PHPStan (pull_request) Successful in 1m12s
Tests / Unit Tests (pull_request) Successful in 47s
Tests / Integration Tests (pull_request) Successful in 57s
- CHANGELOG / readme 1.1.0: flat-first routing for flat-sorted searches (tmdo_router_flat_first) and the all-NULL flat row left after deleting an entity
- ADR-004: NULL sync never inserts a row; flat-first rule; front-end p50 from the R-2.1 acceptance run
- BENCHMARK-SCALE: front-end table from the acceptance run (flat tables backfilled) and the join-order finding behind PR-062c
- Verify: composer phpcs (0)
- PR: PR-064b
2026-09-26 21:07:51 +08:00
wpdev dc9a8e9886 perf(2meet-data-optimizer): drive flat-sorted searches from the flat table
- A filtered search ordered by a flat column is rewritten to SELECT STRAIGHT_JOIN … FROM flat INNER JOIN wp_posts, with posts.ID tie-breaks (ours and plugin-appended) read as the equal flat post_id, so MariaDB sorts one table instead of looking every post up and sorting in a temporary table
- Only when the routed conditions already require a flat row on the sort group, without the copy route or a selective post filter; filter tmdo_router_flat_first turns it off; split_the_query compares with the same rewrite
- Verify: tests/live/run.php router (13/13); integration 522; dev36 page SQL Q07 263→147 ms, Q15 250→142 ms; fe.sh hourly_rate__desc 850→367 ms, ids == native
- PR: PR-062c
2026-09-26 21:05:11 +08:00
wpdev a7134bfb80 fix(2meet-data-optimizer): do not recreate a deleted entity's flat row
- Deleting a post / user / term / comment drops its flat row in the adapter delete hook, then WordPress deletes each meta row; sync_from_native upserted NULL for each and brought the row back (512 orphan rows on dev36). With no native value it now only clears the column on an existing row
- Verify: tests/live/run.php resync (25/25, entity deletes in dual_write and shadow_read leave no flat row); mut.php T8 flat_row NO-ROW; integration 516
- PR: PR-057b
2026-09-26 20:34:28 +08:00
wpdev 4fc59b757e docs(2meet-data-optimizer): ADR-004 search-index mode and 1.1.0 release notes
- ADR-004: HivePress / WooCommerce sites keep native meta as the source of truth and use flat tables as its search index; records the 2.1 write order, shadow_read, Promotion Guard, restore policy and router rules, with known limits
- CONTEXT.md Entity Bridge, BENCHMARK-SCALE.md front-end and write-chain measurements (dev36), CHANGELOG and readme for 1.1.0; version 1.1.0
- composer phpcs uses phpcs.xml like CI (was --standard=WordPress with 2312 false errors)
- Verify: composer phpcs (0); composer test; composer stan
- PR: PR-064
2026-09-26 20:24:42 +08:00
wpdev aa2da274dd perf(2meet-data-optimizer): keep WordPress's split query on routed searches
- posts_request only drops SQL_CALC_FOUND_ROWS, but WordPress then treated the request as filtered and selected full post rows through a temporary table; split_the_query now keeps the ID-first split for routed searches when nothing else changed the request, and posts_request_ids drops SQL_CALC_FOUND_ROWS from the split ID query
- Verify: tests/live/run.php router (12/12, IDs-first request, found_posts == native); dev36 fe.sh price 589→340 ms, price__asc 961→427 ms, rating__desc 1724→872 ms, hourly_rate__desc 855→884 ms (was 1206)
- PR: PR-062b
2026-09-26 20:14:29 +08:00
wpdev ec75721976 feat(2meet-data-optimizer): route meta orderby including the HivePress sort clause
- Sort shape (relation OR of NOT EXISTS + EXISTS on one key, named in orderby): drop it from meta_query, LEFT JOIN the flat table and order by its column, ties by ID
- Named routed clause as the sole orderby, and meta_key + meta_value_num (meta_value on string columns) with IS NOT NULL; stay native when the column would not order like CAST(meta_value AS type), orderby has several keys, or meta_value filters
- Verify: tests/live/run.php router (11/11); fe.sh price__asc / hourly_rate__desc / rating__desc ids == native
- PR: PR-062
2026-09-26 20:04:23 +08:00
wpdev f2e261c522 fix(2meet-data-optimizer): join each routed group and drop the redundant GROUP BY
- posts_join joins the flat table of every routed group (a wp_core key next to a post type group no longer fails with "Unknown column")
- Flat tables are one row per post (UNIQUE post_id): remove the posts_groupby hook; skip the INNER JOIN status/date copy route for NOT EXISTS, which must match posts without a flat row
- Verify: tests/live/run.php router (9/9); qbench Q05 475 == native, no SQL error; price filter EXPLAIN has no Using temporary
- PR: PR-061
2026-09-26 19:47:53 +08:00
wpdev b338925605 fix(2meet-data-optimizer): route a meta query only when every registered clause is routable
- Leave the query to WordPress on OR, nested groups with registered keys, several post types, orderby on a named routed clause, and compare/type pairs whose flat-column semantics differ from native (untyped numeric ranges, CHAR on decimals, NUMERIC on text)
- Wrap LIKE values as %esc_like%; hook pre_get_posts at priority 1000 so plugin clauses (HivePress) are seen
- Verify: wp eval-file tests/live/run.php router (7/7); qbench Q04/Q06/Q08/Q14/Q16 == native on dev36
- PR: PR-060
2026-09-26 19:41:49 +08:00
wpdev 8c049f0cf2 fix(2meet-data-optimizer): restore native meta without overwriting newer values or inventing defaults
- TMDO_Native_Restorer::run() gains a policy: overwrite (flat wins; the
  default in aeav_only, where writes only reached flat and native is
  stale) or only_missing (native wins, flat only fills gaps; the default in
  every other mode, where native is the source of truth and may be newer —
  on dev36 an aeav_only round trip overwrote WooCommerce stock 42 with 0).
  CLI: --overwrite / --only-missing.
- Native values are read first and each owned key is classified as
  missing / differs / same / default_skipped; unchanged values are no
  longer rewritten. A flat value equal to its field default is not
  written for an entity without a native row (dev36: 1,368 invented
  session_count / account_flags rows). --dry-run reports the counts.
- Verify: NativeRestorer* 17 OK (5 new: policy by mode, forced
  overwrite, only-missing in aeav_only, dry-run counts, no invented
  defaults); unit 687 OK, integration 490 OK, phpcs (phpcs.xml) 0,
  stan OK; dev36 `bridge-restore-native user --dry-run`: policy
  only_missing, activity 120 defaults skipped
- PR: PR-058
2026-09-26 19:22:34 +08:00
wpdev 77e338f9c3 fix(2meet-data-optimizer): resync flat rows from native meta after each write
- dual_write / shadow_read no longer write flat in the update_/add_/
  delete_{type}_metadata pre-filters, before WP decides whether native
  changes at all. added_/updated_/deleted_{type}_meta (priority 1) call
  TMDO_Hook_Bus::sync_from_native(), which copies the entity's first native
  row (plain SQL, NULL when gone) into flat. Flat now follows native when WP
  skips (same value), refuses (non-matching prev_value, add $unique on an
  existing key) or deletes (also delete_all: touched IDs are collected from
  the meta IDs in delete_{type}_meta), and a flat value left stale by a
  direct SQL write heals on the next write.
- update_metadata_by_mid key changes: the old key is remembered in
  update_{type}_metadata_by_mid and re-synced too.
- wpdo_after_write / wpdo_after_delete fire from the sync with the same
  arguments; aeav_only keeps the pre-filter short-circuit unchanged.
- $internal_ops lock key includes the entity type (user 5 ≠ post 5).
- Live suite resync: 4 entities × 2 modes × 3 scenarios (old code: 16 of
  24 FAIL); integration: pre-filter leaves flat to the sync, delete_all
  re-syncs every touched user.
- Verify: live resync + shadow_write + serialized + zero_side_effect 33/33
  PASS; unit 687 OK, integration 485 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-057
2026-09-26 19:13:02 +08:00
wpdev 781f171770 fix(2meet-data-optimizer): skip zone deletes on tables that were never created
- TMDO_Zone_Router::delete_post() deleted from wp_wpdo_hot_/cold_{post_type}
  whenever fields were registered for that zone, so every post delete on a
  HivePress site (cold fields registered, module idle, table never created)
  logged a "table doesn't exist" error. Hot and cold deletes now also need
  the table to exist; module state is not checked, so a table left by a
  rolled-back module still loses the rows of deleted posts.
- Live suite zero_side_effect: saving / updating / deleting an hp_listing
  issues no query on missing zone tables (covers PR-054 and this PR).
- Verify: ZoneRouterTest 22 OK; live zero_side_effect + shadow_write +
  serialized 9/9 PASS (old code: zero_side_effect FAIL); unit 687 OK,
  integration 484 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-054b
2026-09-26 19:02:10 +08:00
wpdev b84252d787 fix(2meet-data-optimizer): serve native meta in shadow_read and compare flat values
- In shadow_read, TMDO_Hook_Bus::intercept_get() returned the flat value.
  update_metadata() reads the old value through that filter after the Hook
  Bus has upserted the new one, so WP saw old === new and never wrote native
  meta: every update in shadow_read was lost on demotion (dev36 2026-09-26).
  shadow_read now returns $check (native) and compares flat on a sample of
  reads (filter tmdo_shadow_compare_sample_rate, default 0.1). aeav_only is
  unchanged; the post query router still routes in shadow_read.
- Live suite shadow_write: xfail markers removed, HivePress listing case
  added (new int key hp_rating_count); fixtures gain post() and a
  post_type-scoped field()/flat().
- Verify: live shadow_write + serialized 8/8 PASS (old code: 4 FAIL);
  unit 685 OK, integration 484 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-056
2026-09-26 18:56:45 +08:00
wpdev 08f9e313e0 fix(2meet-data-optimizer): serialize arrays in text fields instead of storing "Array"
- TMDO_Type_Caster: text / textarea values that are arrays or objects are
  stored with maybe_serialize() (was (string) → the literal "Array", which
  bridge-restore-native then wrote back over native meta); from_db()
  unserializes them with TMDO_Safe_Unserialize. text columns are
  VARCHAR(255): longer values are stored NULL (native keeps them) and
  logged as text_overflow.
- Add `wp tmdo repair-array-literals [--entity] [--confirm]`
  (TMDO_Array_Literal_Repair): rewrites flat "Array" values from native
  meta and lists native rows that are "Array" themselves (backup needed).
- Verify: TypeCasterSerializeTest 13 OK, ArrayLiteralRepairTest 3 OK;
  unit 685 OK, integration 483 OK, phpcs (phpcs.xml) 0, stan OK;
  live `serialized` 3 PASS; dev36 repair dry-run lists the value corrupted
  by the 2026-09-26 round trip (user #1 community-events-location)
- PR: PR-055
2026-09-26 18:45:59 +08:00
wpdev 20bd59ddae fix(2meet-data-optimizer): skip cold-zone cache warming unless the cold module is active
- TMDO_Cache_Layer::prefetch() / warm_post() (and so on_save_post /
  on_loop_start) queried wp_wpdo_cold_{post_type} whenever cold fields
  were registered, even with the cold module idle and the table never
  created: every HivePress / WC save logged "table doesn't exist" errors.
  They now return unless the module is write-active and the table exists.
- CacheLayerTest activates the module in setUp; new cases assert zero cold
  queries while idle or when the table is missing.
- Verify: CacheLayerTest 12 OK; unit 671 OK, integration 480 OK,
  phpcs (phpcs.xml) 0, stan OK; dev36 live: seeding listings/products/
  vendors with cold_hp_listing idle → 0 wpdo_cold errors (was hundreds)
- PR: PR-054
2026-09-26 18:34:09 +08:00
wpdev 79a6c4d724 fix(2meet-data-optimizer): refuse aeav_only and cleanup on HivePress / WooCommerce sites
- Add TMDO_Promotion_Guard: HivePress models read every meta key with an
  empty-key get_*_meta() and WooCommerce does the same for products (and
  writes stock/sales with direct SQL), so on those sites native meta must
  stay authoritative. Promotion to aeav_only is refused in
  TMDO_Mode_Manager::set() (covers REST, CLI, phases, auto-promoter) for
  post/user/term/comment with HivePress and post/user with WooCommerce;
  TMDO_Post_Migration::cleanup() and the cleanup phase refuse too.
  Demotion stays open; filter tmdo_promotion_guard_allow overrides.
- Admin notice lists guarded entities already in aeav_only with the
  restore-native command (no forced demotion on read).
- Verify: PromotionGuardTest 8 OK; unit 668 OK, integration 480 OK,
  phpcs (phpcs.xml) 0, stan OK; dev36 live: 4 promotions + post-cleanup
  refused, wpdo_bridge_modes unchanged
- PR: PR-053
2026-09-26 18:29:04 +08:00
wpdev f72a315986 test(2meet-data-optimizer): add live test runner and failing shadow_read write suite
- Add tests/live: a `wp eval-file` runner (PASS/FAIL/XFAIL/XPASS/SKIP) with
  fixtures that override entity modes in process (pre_option) and read
  native / flat values with plain SQL, so WP core meta flows run for real
- shadow_write suite reproduces the dev36 finding: in shadow_read,
  update_*_meta returns false and native meta is never written
  (3 XFAIL marked for PR-056); the dual_write control passes
- Move the dev36 integration harness into tests/live/tools; outputs go to
  cwd / $TMDO_LIVE_OUT, site URL and path are env-configurable
- Verify: wp --path=<dev36> eval-file tests/live/run.php shadow_write
  → 1 passed, 3 xfail; unit 658 OK, integration 480 OK,
  phpcs (phpcs.xml) 0, stan OK
- PR: PR-052
2026-09-26 18:21:31 +08:00
wpdev 2540d7c0e2 Merge pull request 'hardening 2.0: 2meet-data-optimizer (staged rollout, dev22 first)' (#3) from hardening/2.0 into main
Tests / Unit Tests (push) Successful in 49s
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 22s
Tests / PHP Lint (push) Successful in 52s
Tests / Integration Tests (push) Successful in 1m15s
Tests / PHPStan (push) Failing after 38s
Tests / PHPCS (push) Successful in 30s
2026-09-26 10:20:23 +08:00
wpdev b917e45c8c chore(2meet-data-optimizer): make the hardening branch pass PHPStan and PHPCS
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 14s
Tests / PHP Lint (pull_request) Successful in 1m0s
Tests / PHPCS (pull_request) Successful in 1m15s
Tests / Unit Tests (pull_request) Successful in 57s
Tests / Integration Tests (pull_request) Successful in 1m14s
Tests / PHPStan (pull_request) Successful in 34s
- CI PHPStan (level 6, baseline for pre-existing debt) failed on 35 errors in
  code added on hardening/2.0: missing iterable value types (PHPDoc only), a
  redundant method_exists() in uninstall.php, and the new WP-CLI commands
  registered as array('Class', 'instanceMethod') — now array(new Class(), ...)
- Local runs had been masked by a PHPStan tmpDir shared with the act CI runner
  (cached container pointing at a removed phar); use a private tmpDir
- Verify: phpstan (private tmpDir) no errors, phpcs clean on changed files,
  phpunit Unit 658 OK; dev22 (running this branch) CLI post-status-sync /
  bridge-restore-native dry runs OK
2026-09-26 07:45:05 +08:00
wpdev 08492dd795 fix(2meet-data-optimizer): compare empty strings correctly when retiring a group
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 14s
Tests / Unit Tests (pull_request) Successful in 33s
Tests / PHP Lint (pull_request) Successful in 36s
Tests / Integration Tests (pull_request) Successful in 57s
Tests / PHPCS (pull_request) Successful in 35s
Tests / PHPStan (pull_request) Failing after 50s
- retire() read native meta with wpdb::get_col(), which (via get_var())
  returns null for an empty string, so a flat '' written back as '' was
  counted as a mismatch and blocked the retirement. It now reads raw rows
  with get_results().
- The integration test wpdb stub's get_var() / get_col() now read '' as
  null like the real wpdb, so tests see WordPress behaviour.
- Found writing back the sso group on dev21 / dev22 (picture_url = '').
- Verify: core unit 658 / integration 480 green, phpcs clean
- PR: PR-042 follow-up
2026-09-26 04:24:14 +08:00
wpdev ecf4ff4938 refactor(2meet-data-optimizer): retire the SSO AddOn recommendation
- missing_integration_addons() no longer asks for the SSO AddOn when
  2meet-hub-core / 2meet-spoke-sso are active: they register their own
  tables and the `sso` values belong in usermeta (benchmark: no
  measurable gain from the flat table for by-user-id access).
- `sso` joins the deprecated AddOns; for hub / spoke / sso the notice says
  they go without replacement and to run
  `wp tmdo bridge-restore-native user --group=sso --retire` first.
- Verify: core unit 658 / integration 479 green, phpcs clean
- PR: PR-043
2026-09-26 04:07:40 +08:00
wpdev 44a188b7ee feat(2meet-data-optimizer): group-scoped native write-back and group retirement
- TMDO_Native_Restorer::run() takes an optional group; the CLI gains
  `bridge-restore-native <entity> --group=<g>`.
- New retire(): once native meta holds every non-NULL value of a group's
  flat table (checked against the persisted field definitions), forget
  its registry_meta row and rename the table to <table>_retired_<Ymd>, so
  orphan restore no longer routes its keys to the flat table. Refused
  while a plugin still registers the group or any value differs.
  CLI: `--group=<g> --retire`.
- For retiring the user `sso` group (SSO AddOn deprecation).
- Verify: core unit 659 / integration 479 green, phpcs clean
- PR: PR-042
2026-09-26 04:06:31 +08:00
wpdev c70dc99bf9 refactor(2meet-data-optimizer): stop registering the sso group in core
- TMDO_Member_Fields no longer registers user/sso; the definition moved
  unchanged (hash 03543f19) to 2meet-data-optimizer-sso-addon.
- missing_integration_addons(): 2meet-hub-core or 2meet-spoke-sso without
  the SSO AddOn is reported (admin notice + Site Health).
- The deprecated-AddOn notice points hub / spoke users to the SSO AddOn.
- Sites must activate the SSO AddOn before this ships (PR-041); aeav_only
  sites without it fall back to orphan-group restore.
- Verify: core unit 659 / integration 475, SSO 6, HP 142/49, WC 10/22
  green, phpcs clean
- PR: PR-040
2026-09-26 03:39:58 +08:00
wpdev 10671e7b78 perf(2meet-data-optimizer): cache found-rows counts for routed queries
- TMDO_Post_Query_Router drops SQL_CALC_FOUND_ROWS from routed searches
  (posts_request) and answers found_posts_query from a COUNT(*) of the
  same query, cached in the object cache under posts' last_changed.
- Hook Bus flat post writes and deletes move posts' last_changed: in
  aeav_only they skip update_metadata()'s own hooks, so query caches
  would otherwise outlive the write.
- Bench (1M): cached page 8 ms (was 694 / 363 ms for scenarios 1 / 2);
  after invalidation 8 ms + COUNT 608 / 344 ms, on par with before.
- REST X-WP-Total COUNT is on the frozen Hot zone path (ADR-003): not
  changed.
- Verify: core unit 659 / integration 475, HP 142/49, WC 10/22 green,
  phpcs clean
- PR: PR-030
2026-09-26 01:44:45 +08:00
wpdev 754f904e3b perf(2meet-data-optimizer): route status and date ordering to entity tables
- TMDO_Post_Query_Router: when a group's post_status / post_date copies
  are ready and none is NULL (one index probe per group per request), a
  single-post-type search with explicit statuses and date ordering INNER
  JOINs the flat table, filters the copied status, orders by the copied
  post_date and skips GROUP BY. Otherwise the query keeps its old shape.
- New posts_orderby filter; bench gains an `entity+copy` variant.
- Bench (1M, with SQL_CALC_FOUND_ROWS): scenario 1 p95 3,739 -> 773 ms
  (p50 670 -> 770); scenario 2 p50 356 -> 448 ms. Without found-rows the
  same shape runs in 8 ms (PR-030).
- Verify: core unit 659 / integration 471 green, phpcs clean
- PR: PR-029
2026-09-26 01:41:07 +08:00
wpdev aeca12b41d feat(2meet-data-optimizer): store post_status and post_date copies in post entity tables
- TMDO_Post_Status_Sync: post group tables get post_status / post_date
  columns (posts' collation) and an (post_status, post_date) index.
  Brand-new empty tables get them on creation; existing tables only via
  `wp tmdo post-status-sync [--dry-run] [--batch]`, never as DDL on a
  populated table in a web request. Per-group state in
  tmdo_post_status_columns ('columns' | 'ready').
- transition_post_status refreshes the copies; Hook Bus upserts that
  create a post row fill them in (existing rows keep theirs).
- Bench (1M rows): add columns 0.05 s, index 5.3 s, batched backfill 32 s,
  0 mismatches afterwards.
- Verify: core unit 659 / integration 466 green, phpcs clean
- PR: PR-028
2026-09-26 01:25:20 +08:00
wpdev f4eee2338b feat(2meet-data-optimizer): flag new Hot zone registrations as deprecated
- ADR-003 step 4 (core side): TMDO_Schema_Registry::register() raises
  _doing_it_wrong() the first time each provider registers a zone=hot
  field in a request (zone omitted defaults to hot). Fields still
  register, so existing hot data stays readable.
- Cold / warm / archive registrations are unaffected.
- Test bootstrap's _doing_it_wrong stub now records calls.
- Verify: core unit 658 / integration 459, HP 142/49, WC 10/22 green,
  phpcs clean
- PR: PR-038
2026-09-26 01:16:05 +08:00
wpdev 65c75dfc8d feat(2meet-data-optimizer): support composite indexes on entity groups
- register_group() accepts $args['indexes'] (name => ordered keys); indexes on
  unknown keys are dropped. Kept outside the field definition, so schema
  hashes are unchanged.
- Schema_Manager::ensure_group_indexes() adds missing cidx_* indexes to the
  existing flat table in place; the applied definition is remembered in
  tmdo_entity_indexes so later requests skip SHOW INDEX.
- Bench (1M listings, scenario 2): Entity p50 337 ms -> 278 ms with
  (hp_verified, hp_price), on par with Hot (269 ms).
- Verify: core unit 656 / integration 459 green, phpcs clean
- PR: PR-027
2026-09-26 01:05:23 +08:00
wpdev d6b2e417f4 docs(2meet-data-optimizer): decide hot table vs entity table consolidation (ADR-003)
- Compare the Hot zone and Entity bridge (capabilities, 1M-listing benchmark, per-site usage: no 2meet-DO site uses the Hot zone; only legacy dev34 / dev10 hold Hot copies of postmeta)
- Decision (user, 2026-09-26): converge on Entity; freeze the Hot zone, skip PR-026, keep PR-027, move Hot-only HivePress fields into new Entity groups, stop Hot registrations
- PR: PR-025
2026-09-26 00:49:20 +08:00
76 changed files with 6241 additions and 323 deletions
+7 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer
* Plugin URI: https://2meet.io/2meet-data-optimizer
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
* Version: 1.0.2
* Version: 1.1.1
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// ── Constants ──────────────────────────────────────────────────────────────
define( 'TMDO_VERSION', '1.0.2' );
define( 'TMDO_VERSION', '1.1.1' );
define( 'TMDO_DB_VERSION', '2.1.0' );
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
@@ -234,6 +234,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// ── v2.0.0 Engine + Adapters + Modules ───────────────────────────────────
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -260,6 +261,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) );
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
@@ -268,6 +270,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/engine/class-tmdo-auto-promoter.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
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-user.php';
@@ -291,6 +294,8 @@ if ( is_admin() ) {
TMDO_Deprecated_Addons_Notice::register();
require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php';
TMDO_Missing_Addons_Notice::register();
require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php';
TMDO_Promotion_Guard_Notice::register();
}
// ── WP-CLI ─────────────────────────────────────────────────────────────────
+76
View File
@@ -7,6 +7,82 @@ Versioning follows [Semantic Versioning](https://semver.org/).
---
## [1.1.1] — 2026-09-26 — Security:公開 REST 不再回傳私有欄位
**性質**:安全修正(Critical)。1.0.x 起即存在;1.1.0 zip 已撤回(`dist/withdrawn/`)。
### Security
- **公開的 `GET /wpdo/v1/listings`、`/wpdo/v1/listings/{id}` 會把所有已註冊的 hot / cold
欄位回傳給未登入者**:`TMDO_Schema_Registry::register()` 的 `show_in_rest` 預設為 true,
而 addon 都沒有設定它。dev36 實測可取得 infocards 的 `tmeetic_ig_access_token_enc`、
`tmeetic_ig_token_expiry`、`tmeetic_ig_user_id`,以及 WooCommerce 的 `_purchase_note`。
- `show_in_rest` 預設改為 **false**:要公開的欄位必須明確宣告。
- 受保護的 meta key(底線開頭)與名稱像憑證的 key(token、secret、password、api key、
private key、credential、salt、nonce)即使宣告公開也一律不輸出
(`TMDO_Schema_Registry::rest_exposable()`)。
- Zone A 列表路徑在沒有任何公開欄位時不再回傳整列(fail-closed);篩選參數與 orderby
只接受公開欄位,避免藉由篩選 / 排序推測隱藏值。
- **相容性**:依賴預設公開的欄位,現在需在註冊時加上 `'show_in_rest' => true`,或使用
既有的 `tmdo_rest_listing_visible_fields` filter。
- 測試:`RestApiTest` 新增 3 個;live suite `rest`(未登入 GET 不得出現私有 / 憑證類 key)。
---
## [1.1.0] — 2026-09-26 — HivePress / WooCommerce 搜尋索引模式(hardening 2.1)
**性質**:資料正確性修正 + 查詢改寫補強。無 schema 變更。決策與設計見
`docs/adr-004-search-index-mode-for-hp-wc.md`;搭配 HivePress AddOn 0.2.0。
### Added
- **Promotion Guard**(`TMDO_Promotion_Guard`):HivePress 啟用時 post / user / term /
comment、WooCommerce 啟用時 post / user 不得升到 aeav_only 或執行 cleanup
(`WP_Error( 'promotion_guarded' )`,filter `tmdo_promotion_guard_allow`);已在
aeav_only 的站只顯示後台 notice。(PR-053)
- **`wp tmdo repair-array-literals [--entity] [--confirm]`**:把扁平表中的 `"Array"`
從原生修回;原生也被污染的列出清單。(PR-055)
- **`bridge-restore-native --overwrite / --only-missing`**:預設依模式(aeav_only 覆寫、
其他只補缺);報告新增 `missing` / `differs` / `same` / `default_skipped`。(PR-058)
- **live 測試層** `tests/live/`(在真實站台以 `wp eval-file` 執行):suites
`shadow_write`、`serialized`、`resync`、`router`、`zero_side_effect`,以及 qbench /
fe.sh 等量測工具。(PR-052)
### Changed
- **Hook Bus 寫入順序**:dual_write / shadow_read 由 WordPress 寫原生,扁平表在
`added_ / updated_ / deleted_{type}_meta` 從原生重新同步(含 `delete_all`、
`update_metadata_by_mid` 改 key)。(PR-057)
- **shadow_read** 讀原生,並抽樣比對扁平值(`tmdo_shadow_compare_sample_rate`)。(PR-056)
- **Post query router**:
- 全有或全無:OR、多 post type、含已註冊 key 的巢狀 group、type 語意與欄位不符的
clause 讓整個查詢維持原生;LIKE 包 `%esc_like%`;`pre_get_posts` priority 1000。(PR-060)
- 每個 group 各自 JOIN、不加 GROUP BY;NOT EXISTS 不走狀態 / 日期副本路由。(PR-061)
- meta orderby:HivePress `_sort`(NOT EXISTS + EXISTS)形狀、具名 clause、
`meta_key` + `meta_value_num`。(PR-062)
- routed 搜尋維持 WordPress 的 split query(ID-first)。(PR-062b)
- 依扁平欄位排序且有篩選的搜尋改由扁平表驅動(`STRAIGHT_JOIN`,posts.ID tie-break 改讀等值的
`post_id`),不再以 temporary table 排序;filter `tmdo_router_flat_first` 可關閉。(PR-062c)
- `composer phpcs` 改用 `phpcs.xml`(與 CI 相同)。
### Fixed
- shadow_read 下 `update_*_meta()` 回傳 false、原生沒有寫入(資料遺失)。(PR-056)
- 陣列 / 物件存進 text 欄位變成 `"Array"`;text 超過 255 字元改存 NULL 並記錄
`text_overflow`。(PR-055)
- `bridge-restore-native` 覆蓋較新的原生值(WC 庫存)、替每個 user 捏造預設值列。(PR-058)
- Cold / Hot 表不存在時,`save_post`、`loop_start`、刪除文章仍查詢它們(DB error)。(PR-054、PR-054b)
- 刪除 post / user / term / comment 後,扁平表留下全 NULL 的孤兒列(PR-057 的 NULL 同步會重建已刪的列)。(PR-057b)
- Router:Q05(wp_core 與另一 group 的 key 並用)`Unknown column`;OR / 多 post type /
無 type 數值比較 / 具名 orderby 的結果與原生不同。(PR-060〜062)
### Docs
- ADR-004、`CONTEXT.md` Entity Bridge(寫入順序、shadow_read、搜尋索引模式、Router 規則)、
`docs/BENCHMARK-SCALE.md` 前台與寫入鏈實測。(PR-064)
---
## [1.0.2] — 2026-08-15 — NinjaFirewall 相容性:WAF 設定選項保護
**性質**:防禦性修正 + 文件。無 schema 變更,無破壞性變更。
+23
View File
@@ -135,6 +135,29 @@ Newer anti-EAV system (v2.5+) covering user, term, comment, and post entities vi
The Entity Bridge and the Zone system coexist; the Sync Bridge's
`is_owned_by_entity_bridge()` guard prevents duplicate writes.
**Write order (v1.1.0).** In `dual_write` / `shadow_read` the Hook Bus pre-filters
only decide the route and return `null`: WordPress writes native meta itself, and the
flat value is then re-synced *from native* in `added_/updated_/deleted_{type}_meta`
(priority 1) — `TMDO_Hook_Bus::sync_from_native()` reads the first native row with
direct SQL; no native row → flat NULL. `aeav_only` keeps the pre-filter short-circuit.
**shadow_read** reads native meta (WordPress meta cache) and compares a sample of reads
with the flat value (`tmdo_shadow_compare_sample_rate`, default 0.1). Flat tables serve
queries only: `TMDO_Post_Query_Router` is active from `shadow_read` up.
**Search-index mode** (ADR-004). On HivePress / WooCommerce sites native meta is the
source of truth and flat tables are its search index: `TMDO_Promotion_Guard` refuses
`aeav_only` and cleanup there (post/user/term/comment with HivePress, post/user with
WooCommerce), because both read meta with an empty key and WooCommerce updates stock
and sales with direct SQL. Override: filter `tmdo_promotion_guard_allow`.
**Post query router rules.** A query is rewritten only when the flat table returns
what native `meta_query` would: every registered clause routable (else the whole query
stays native), AND relation, one post type, column type matching the clause's CAST
semantics, LIKE as `%esc_like%`. Meta ordering (the HivePress `_sort` NOT EXISTS/EXISTS
shape, a named clause, `meta_key` + `meta_value_num`) is routed only as the sole
`orderby` on a column that orders like native.
---
## Migration Phase (Strategy pattern)
+10 -2
View File
@@ -6,7 +6,9 @@
* mobile-bridge, playlist, quotation, spoke) only registered tables and fields
* on the partner's behalf. Partner plugins now register themselves with the
* 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
*/
@@ -23,7 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
final class TMDO_Deprecated_Addons_Notice {
/** 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.
@@ -79,5 +81,11 @@ final class TMDO_Deprecated_Addons_Notice {
),
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' )
);
}
}
}
@@ -0,0 +1,63 @@
<?php
/**
* Admin notice when an entity is in aeav_only on a HivePress / WooCommerce site.
*
* Those plugins read native meta directly, so on an aeav_only entity they read
* stale (or, after cleanup, missing) values. The Promotion Guard refuses new
* promotions; sites promoted before it existed are only warned — demoting on
* read would leave them reading whatever native meta is left.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Warns administrators about guarded entities that are already in aeav_only.
*/
final class TMDO_Promotion_Guard_Notice {
/**
* Hook the notice.
*
* @return void
*/
public static function register(): void {
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
}
/**
* Print the notice for administrators when a guarded entity is in aeav_only.
*
* @return void
*/
public static function render(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$violations = TMDO_Promotion_Guard::violations();
if ( empty( $violations ) ) {
return;
}
$lines = array();
foreach ( $violations as $entity_type => $integrations ) {
$lines[] = sprintf( '%s(%s):wp tmdo bridge-restore-native %s --demote', $entity_type, implode( ' / ', $integrations ), $entity_type );
}
printf(
'<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>',
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
esc_html(
sprintf(
/* translators: %s: "entity (integrations): command" list */
__( '以下實體處於 aeav_only,但這些外掛會直接讀取原生 meta,可能讀到過期或遺失的資料。請先備份,再把扁平資料寫回原生並降級:%s', '2meet-data-optimizer' ),
implode( ';', $lines )
)
)
);
}
}
+49 -4
View File
@@ -282,8 +282,8 @@ class TMDO_CLI_Post {
* wp tmdo post-reconcile-groups --dry-run
* wp tmdo post-reconcile-groups
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
* @param array<int, string> $args Positional arguments (unused).
* @param array<string, string> $assoc_args Named arguments.
*/
public function post_reconcile_groups( $args, $assoc_args ): void {
$dry_run = isset( $assoc_args['dry-run'] );
@@ -303,6 +303,50 @@ class TMDO_CLI_Post {
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<int, string> $args Positional arguments (unused).
* @param array<string, string> $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) ─────────────────────────────────────────────────
/**
@@ -881,5 +925,6 @@ WP_CLI::add_command( 'wpdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchm
WP_CLI::add_command( 'tmdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
WP_CLI::add_command( 'wpdo 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( 'tmdo post-reconcile-groups', array( 'TMDO_CLI_Post', 'post_reconcile_groups' ) );
WP_CLI::add_command( 'wpdo post-reconcile-groups', array( new TMDO_CLI_Post(), 'post_reconcile_groups' ) );
WP_CLI::add_command( 'tmdo post-reconcile-groups', array( new TMDO_CLI_Post(), 'post_reconcile_groups' ) );
WP_CLI::add_command( 'tmdo post-status-sync', array( new TMDO_CLI_Post(), 'post_status_sync' ) );
+101 -6
View File
@@ -162,7 +162,15 @@ class TMDO_CLI_V2 {
* : post | user | term | comment
*
* [--dry-run]
* : Count the flat rows per group; write nothing.
* : Count the flat rows per group and how their values compare with native
* meta (missing / differs / same / default skipped); write nothing.
*
* [--overwrite]
* : Flat values replace differing native values. Default in aeav_only.
*
* [--only-missing]
* : Only fill native rows that do not exist; never replace. Default in every
* other mode, where native meta is the source of truth.
*
* [--batch=<n>]
* : Rows per batch. Default 500.
@@ -170,29 +178,114 @@ class TMDO_CLI_V2 {
* [--demote]
* : 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
*
* wp tmdo bridge-restore-native user --dry-run
* wp tmdo bridge-restore-native user --demote
* wp tmdo bridge-restore-native user --group=sso --retire
*
* @param array<int, string> $args Positional arguments.
* @param array<string, string> $assoc_args Named arguments.
*/
public function bridge_restore_native( $args, $assoc_args ): void {
$entity = (string) ( $args[0] ?? '' );
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--batch=<n>] [--demote]' );
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--overwrite|--only-missing] [--batch=<n>] [--demote] [--group=<group> [--retire]]' );
}
$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>.' );
}
if ( isset( $assoc_args['overwrite'] ) && isset( $assoc_args['only-missing'] ) ) {
WP_CLI::error( 'Use either --overwrite or --only-missing, not both.' );
}
$policy = isset( $assoc_args['overwrite'] ) ? 'overwrite' : ( isset( $assoc_args['only-missing'] ) ? 'only_missing' : '' );
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group, $policy );
WP_CLI::log( "Policy: {$report['policy']}" );
foreach ( $report['groups'] as $group => $g ) {
WP_CLI::log( sprintf( '%s/%s: %d row(s)%s', $entity, $group, $g['rows'], $dry_run ? '' : sprintf( ', %d native value(s) written', $g['written'] ) ) );
WP_CLI::log(
sprintf(
'%s/%s: %d row(s) — native missing %d, differs %d, same %d, default skipped %d%s',
$entity,
$group,
$g['rows'],
$g['missing'],
$g['differs'],
$g['same'],
$g['default_skipped'],
$dry_run ? '' : sprintf( '; %d native value(s) written', $g['written'] )
)
);
}
if ( $dry_run ) {
WP_CLI::success( '[dry-run] Nothing written.' );
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.' );
}
/**
* Find flat text / textarea values stored as the literal "Array" (pre-1.1.0
* type caster) and rewrite them from native meta.
*
* Native rows that are "Array" too cannot be repaired here; they are listed
* so they can be restored from a backup.
*
* ## OPTIONS
*
* [--entity=<entity>]
* : post | user | term | comment. Default: all four.
*
* [--confirm]
* : Write the repairs. Without it the command only reports.
*
* ## EXAMPLES
*
* wp tmdo repair-array-literals
* wp tmdo repair-array-literals --entity=user --confirm
*
* @param array<int, string> $args Positional arguments (unused).
* @param array<string, string> $assoc_args Named arguments.
*/
public function repair_array_literals( $args, $assoc_args ): void {
$entities = isset( $assoc_args['entity'] ) ? array( (string) $assoc_args['entity'] ) : TMDO_Entity_Registry::VALID_ENTITY_TYPES;
$apply = isset( $assoc_args['confirm'] );
foreach ( $entities as $entity ) {
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
WP_CLI::error( "Unknown entity: {$entity}" );
}
$report = TMDO_Array_Literal_Repair::run( $entity, $apply );
WP_CLI::log( sprintf( '%s: %d "Array" value(s) in flat tables, %d repairable from native, %d repaired.', $entity, $report['found'], $report['repairable'], $report['repaired'] ) );
foreach ( $report['unrepairable'] as $row ) {
WP_CLI::log( sprintf( ' unrepairable %s/%s #%d (%s)', $row['group'], $row['key'], $row['id'], $row['reason'] ) );
}
foreach ( $report['native_polluted'] as $row ) {
WP_CLI::warning( sprintf( 'native meta is "Array": %s %s #%d — restore it from a backup', $entity, $row['key'], $row['id'] ) );
}
}
WP_CLI::success( $apply ? 'Repair finished.' : '[dry-run] Nothing written. Re-run with --confirm to repair.' );
}
/**
* Audit per-module state + shadow_read flags.
*
@@ -825,8 +918,10 @@ WP_CLI::add_command( 'wpdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status'
WP_CLI::add_command( 'tmdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) );
WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'wpdo bridge-restore-native', array( 'TMDO_CLI_V2', 'bridge_restore_native' ) );
WP_CLI::add_command( 'tmdo bridge-restore-native', array( 'TMDO_CLI_V2', 'bridge_restore_native' ) );
WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
WP_CLI::add_command( 'tmdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
WP_CLI::add_command( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) );
+1 -1
View File
@@ -36,7 +36,7 @@
"scripts": {
"test": "phpunit --configuration phpunit.xml",
"test:integration": "phpunit --configuration phpunit-integration.xml",
"phpcs": "phpcs --standard=WordPress includes/ admin/ cli/ modules/ 2meet-data-optimizer.php uninstall.php",
"phpcs": "phpcs --standard=phpcs.xml",
"stan": "phpstan analyse --no-progress",
"stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon"
},
+39
View File
@@ -54,11 +54,50 @@ SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個
1. **原生 meta_query 在百萬規模下無法使用。** 任何以 meta 為條件的搜尋都要 14–17 秒,扁平化是必要的,不是錦上添花。
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 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。
- **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 倍。
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)**,絕對值很小,不是瓶頸。
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
## 前台與寫入鏈實測(dev36,Release 2.1,2026-09-26)
上面的數字是純 SQL。這一節是真實站台(dev36:HivePress 1.7.31 + Marketplace、WooCommerce 11.1.2、Redis object cache、listing 約 10 萬筆)上的整頁與整條寫入鏈,決策背景見 `adr-004-search-index-mode-for-hp-wc.md`。
**前台搜尋**(`tests/live/tools/fe.sh`:每個 URL 5 次取中位數,`X-S36-Ms` = 整個請求到 `template_redirect`)。「routed」是 post 模式 shadow_read + 核心 2.1 router + HivePress AddOn 0.2.0;「1.0.x」是整合測試當時的 router(報告 `notes/tmdo-integration-test-dev36-2026-09-26.md`)。
| URL | 原生 ms | 1.0.x ms | 2.1 routed ms | ids 與原生相同 |
|---|---:|---:|---:|---|
| listing 價格區間 | 614 | 496 | **323** | 是 |
| listing 價格區間 + `_sort=price__asc` | 975 | 1,062 | **435** | 是 |
| listing 時薪區間 + `_sort=hourly_rate__desc` | 850 | — | **367** | 是 |
| listing `_sort=rating__desc` | 1,697 | 1,720 | **866** | 是 |
| listing 關鍵字 + 價格區間 | 754 | — | 764 | 是 |
| vendor 時薪區間 | 102 | — | 97 | 是 |
| WC 商店價格篩選 / 價格排序 | 94 / 111 | 88–94 | 109 / 88 | 是(WC 用 lookup 表,不經 router) |
(R-2.1 驗收,扁平表已從原生回填、含 PR-062c;兩輪量測見 `notes/tmdo-acceptance-2.1-2026-09-26.md`。)
- HivePress 搜尋仍帶 tax_query(排除一個 term),WordPress 因此加上 `GROUP BY posts.ID`,排序需要 temporary + filesort;這是目前 311–428 ms 的主要成本,尚未達到 R-2.1 的 300 ms 目標。
- PR-062b 之前,routed 搜尋因 `posts_request` 改寫而失去 WordPress 的 split query,改撈整列 `posts.*`:同一條 SQL 撈 `*` 要 590 ms,只撈 ID 為 312 ms。
- PR-062c 之前,依扁平欄位排序的 routed 查詢由 `wp_posts`(type / status 索引,約 56k 列)驅動、逐筆查扁平表再以 temporary table 排序;拿掉 GROUP BY 或只換 tie-break 都無效(約 300 ms),改由扁平表驅動並把 tie-break 改讀 `post_id` 後只剩單表 filesort:`meta_value_num` 263→147 ms、具名排序 250→142 ms、HP 時薪篩選 + 排序 300→36 ms(只計 SQL)。依日期排序的搜尋由扁平表驅動反而變慢(74→137 ms),所以 flat-first 只用在扁平欄位排序。
- 量測 gotcha:symlink 切換(`plugins/` → `plugins-dev`)後,PHP-FPM 的 opcache(`revalidate_path=0`)仍執行舊目標的程式,WP-CLI 不受影響;前台數字要先確認實際載入的檔案。
**WP_Query**(`tests/live/tools/qbench.php`,`fields=ids`,20 筆一頁):價格區間 296→76 ms、價格 + verified 360→53 ms、rating ≥ 4.5 248→33 ms、rate + `_thumbnail_id` EXISTS 24→9 ms;OR、多 post type、無 type 數值比較等維持原生,結果與原生相同。
**寫入鏈開銷**(單一程序,disabled 與 dual_write 交替兩輪取平均):
| 寫入 | disabled | dual_write | 增加 |
|---|---:|---:|---:|
| HP listing | 754 ms / 348 q | 976 ms / 434 q | +29% |
| WC product | 215 ms / 129 q | 489 ms / 206 q | +127% |
| vendor(user + post) | 444 ms / 162 q | 630 ms / 230 q | +42% |
WC product 的成本來自 `wc_product` 群組,Release 2.2 移除 WC 扁平化後消失。
## 優化候選(待使用者確認後排入 backlog)
| # | 候選 | 針對 | 預期效果 |
+78
View File
@@ -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)。
+100
View File
@@ -0,0 +1,100 @@
# ADR-004:HivePress / WooCommerce 站採「搜尋索引模式」
- **狀態**:已接受(2026-09-26)
- **日期**:2026-09-26
- **相關**:dev36 整合測試報告(`notes/tmdo-integration-test-dev36-2026-09-26.md`)、ADR-001、ADR-003、backlog PR-052〜PR-063(Release 2.1)
## 背景
2026-09-26 在 dev36(HivePress 1.7.31 + Marketplace + 10 個擴充、WooCommerce 11.1.2、Redis、10 萬筆 listing)做整合與壓力測試,確認:
1. **HivePress model 與 WC data store 用空 key 讀 meta**(`get_post_meta( $id )`),1.0.x 的 Hook Bus 對空 key 放行、只讀原生。所以 aeav_only 或 cleanup 之後,HP 的 verified / rating、WC 的價格與顧客資料讀成空值(dev20 / dev21 盤點:WC 商品 0/20 可購買)。
2. **WC 以直接 SQL 更新 `_stock`、`total_sales`**,扁平表跟不上。
3. **shadow_read 會遺失寫入**:WP 的 `update_metadata()` 經 `get_metadata_raw` 讀舊值,攔截器從扁平表回答,新舊相同就不寫原生。
4. **Router 在 OR、LIKE、多 post type、跨 group、具名 orderby 上結果錯誤**,HivePress 的 `_sort` 沒被改寫,前台搜尋幾乎沒有變快。
5. **陣列值被存成 `"Array"`**。
帶 key 的單筆讀取本來就沒有速度優勢:`update_meta_cache()` 一次載入全部 key,之後都是記憶體讀取。扁平表真正的價值在**搜尋與篩選**(`BENCHMARK-SCALE.md` 結論 1、2)。
## 決策(使用者 2026-09-26)
1. **HP / WC 站採「搜尋索引模式」**:原生 meta 是唯一真實資料來源,扁平表是它的搜尋索引。模式最高到 `shadow_read`;`aeav_only` 與 cleanup 由 Promotion Guard 擋下。
2. **移除 WC 扁平化**(Release 2.2:core 的 `post/wc_product` 群組、WC AddOn 的 user `hot` 群組與 hot-zone 欄位)。
3. **既有 aeav_only 站(dev20 / 21 / 22)先唯讀盤點**,處置由使用者決定(`notes/ops-aeav-sites-audit-2026-09-26.md`)。
4. **分兩版**:2.1 = 止血 + 資料正確性 + Router 與 HP 排序;2.2 = 範圍收斂 + 漂移偵測 + CLI 與診斷。
## 設計(Release 2.1)
### 寫入:扁平表跟隨原生
- **dual_write / shadow_read** 下,pre-filter(`update_/add_/delete_{type}_metadata`)只做路由判斷後回 `null`,讓 WordPress 自己寫原生。
- 扁平表在 `added_ / updated_ / deleted_{type}_meta` action(priority 1)裡**從原生重新同步**:直接 SQL 讀原生第一筆 row(`ORDER BY meta_id LIMIT 1`,不經 filter),扁平值 = `to_db( maybe_unserialize( v ) )`,原生沒有 row 時設為 NULL(`TMDO_Hook_Bus::sync_from_native()`)。
- `delete_metadata( …, $delete_all )`:`deleted_` action 拿到的 object id 是 0,所以在 `delete_{type}_meta` 先記下 object ids。
- `update_metadata_by_mid()` 改 key:新舊兩個 key 都重新同步。
- 原生沒有值時只把既有列的欄位設為 NULL,**不新增列**:刪除實體時 adapter 先刪扁平列,WordPress 才逐筆刪 meta。
- **aeav_only** 保留原本的 pre-filter short-circuit(非 HP / WC 站仍可使用)。
### 讀取:shadow_read = 讀原生、抽樣比對扁平
`intercept_get` 在 shadow_read 回 `$check`(讓 WordPress 讀原生),並以 `tmdo_shadow_compare_sample_rate`(預設 0.1)抽樣比對扁平值、記錄差異。查詢加速不受影響,因為 Router 仍由 `reads_from_flat()`(shadow_read 以上)控制。
### Promotion Guard
`TMDO_Promotion_Guard`,由 `TMDO_Mode_Manager::set()`、`TMDO_Post_Migration::cleanup()` 與 `TMDO_Phase_Cleanup::execute()` 呼叫:
- HivePress 啟用時,post / user / term / comment 不得升到 aeav_only 或執行 cleanup;WooCommerce 啟用時,post / user 不得。
- 回傳 `WP_Error( 'promotion_guarded' )`;特殊情況用 filter `tmdo_promotion_guard_allow` 放行。
- 已經在 aeav_only 的站**讀取時不強制降級**(降級會讀到空的原生),只在後台顯示 notice。處置用 `wp tmdo bridge-restore-native <entity> --demote`。
### 回寫原生(bridge-restore-native)
依模式決定策略:aeav_only 預設 `overwrite`(寫入只到扁平表、原生已過期),其他模式預設 `only_missing`(原生可能較新,例如 WC 直接 SQL 寫庫存)。相同的值不重寫;扁平值等於欄位 default 且原生沒有 row 時不寫(不捏造預設值)。
### 序列化值
`text` / `textarea` 遇到陣列或物件時 `maybe_serialize()`,讀出時 `TMDO_Safe_Unserialize`;`text`(VARCHAR(255))超長存 NULL 並記 `text_overflow`。既有的 `"Array"` 由 `wp tmdo repair-array-literals` 從原生修復。
### Router:只在語意與原生相同時路由
`TMDO_Post_Query_Router`(`pre_get_posts` priority 1000,排在外掛於預設 priority 加的條件之後):
- **全有或全無**:任何一個已註冊 key 的 clause 不能路由,整個查詢交回 WordPress。不能路由的情況:
- top-level OR
- 含已註冊 key 的巢狀 group(排序形狀除外)
- 多個 post type
- 白名單以外的 compare
- type 與欄位語意不同:無 type(CHAR)的數值範圍比較、decimal 欄位的 CHAR 比較、text 欄位用 NUMERIC
- **LIKE** 包成 `%esc_like%`。
- **每個 group 各自 LEFT JOIN**;扁平表一文章一列(UNIQUE `post_id`),不加 GROUP BY。
- 狀態 / 日期副本路由遇到 NOT EXISTS 時停用:INNER JOIN 會漏掉沒有扁平列的文章。
- **排序**(`wpdo_post_sort`):
- **HivePress `_sort` 形狀**:OR 裡剛好是同 key、同 type 的 NOT EXISTS + EXISTS,其中一個具名,並且是唯一的 orderby。這個 group 從 meta_query 移除,改成 LEFT JOIN 扁平表並 `ORDER BY col, ID DESC`;ASC 時 NULL 排最前,與原生相同。
- **具名 clause** 作為唯一的 orderby。
- **`meta_key` + `meta_value_num`**(字串欄位用 `meta_value`),加上 `col IS NOT NULL`(原生是 INNER JOIN)。
- 欄位排序不等於 `CAST( meta_value AS type )` 時退回原生。
- **flat-first**:依扁平欄位排序、且 routed 條件已要求扁平列存在(同一 group、非 NOT EXISTS)、沒有 `post__in` / `author` 等高選擇性條件時,改寫成 `SELECT STRAIGHT_JOIN … FROM flat INNER JOIN wp_posts`,ORDER BY 的 `wp_posts.ID` 改讀等值的 `post_id`,排序只在一張表完成。依日期排序的搜尋不套用(由 posts 驅動較快)。filter `tmdo_router_flat_first` 可關閉。
- **維持 WordPress 的 split query**:`posts_request` 移除 `SQL_CALC_FOUND_ROWS`(總數改由快取的 COUNT 回答)會讓 WordPress 以為 request 被改過而撈整列。`split_the_query` 對 routed 查詢恢復 ID-first,`posts_request_ids` 再移除一次 `SQL_CALC_FOUND_ROWS`。
HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `optimize_query` / `optimize_search`。
## 後果
**好處**
- 資料正確性回到 WordPress 原生語意:HP / WC 的空 key 讀取、直接 SQL 寫入都不再造成資料不一致或遺失。
- 查詢加速保留,而且擴及 HivePress 前台的篩選與排序(實測見 `BENCHMARK-SCALE.md`「前台與寫入鏈實測」)。
- 任何時候都可以降回 disabled,原生資料完整。
**代價與限制**
- **寫入開銷**:dual_write / shadow_read 每次 meta 寫入多一次原生讀取與一次扁平寫入。dev36 實測 HP listing 整條寫入鏈 +29%、WC product +127%(後者在 2.2 移除 WC 扁平化後消失)。
- **沒有讀取加速**:帶 key 的單筆讀取仍走原生(WordPress meta cache)。
- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 的 `drift-check` 與重新啟用時的增量 backfill(E01 / E02)處理;在那之前,大量匯入後要重新 backfill。
- **decimal 欄位搭配 NUMERIC / DECIMAL**:原生先 CAST 成整數(`DECIMAL` 預設 (10,0)),扁平表精確比較,因此帶小數的值在區間邊界上可能不同。dev36 資料沒有這種值。
- **text 欄位超過 255 字元**存 NULL,這類值的篩選結果會與原生不同(有 `text_overflow` 記錄)。
- HivePress 搜尋 p50 為 321–866 ms(原生 600–1,713 ms),尚未達到 R-2.1 的 300 ms 目標(驗收:`notes/tmdo-acceptance-2.1-2026-09-26.md`)。價格篩選頁的 SQL 只剩約 75 ms、其餘為頁面渲染;純排序(如 `rating__desc`)必須 LEFT JOIN 保留沒有值的文章,無法 flat-first。
## 不在範圍內
- aeav_only 的空 key 讀取合併(B02):HP / WC 站不再使用 aeav_only,是否實作依其他站需求決定。
- Hot zone 的移除(ADR-003 的後續步驟)。
+15 -2
View File
@@ -40,7 +40,7 @@ class TMDO_Cache_Layer {
}
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
if ( empty( $cold_keys ) ) {
if ( empty( $cold_keys ) || ! self::cold_active( $post_type ) ) {
return;
}
@@ -108,7 +108,7 @@ class TMDO_Cache_Layer {
*/
public static function warm_post( int $post_id, string $post_type ): void {
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
if ( empty( $cold_keys ) ) {
if ( empty( $cold_keys ) || ! self::cold_active( $post_type ) ) {
return;
}
@@ -120,6 +120,19 @@ class TMDO_Cache_Layer {
TMDO_Zone_Cold::get_blob( $post_id, $post_type );
}
/**
* Whether the cold zone holds data for this post type: its module writes
* to the cold table and the table exists. Registered cold fields alone do
* not mean either (zero side effect while the module is idle).
*
* @param string $post_type Post type.
* @return bool
*/
private static function cold_active( string $post_type ): bool {
return TMDO_Feature_Flags::is_write_active( TMDO_Zone_Router::module_name( 'cold', $post_type ) )
&& TMDO_Schema_Manager::table_exists( TMDO_Zone_Cold::table( $post_type ) );
}
/**
* Flush all cold cache for a post type.
*
+3
View File
@@ -109,6 +109,9 @@ class TMDO_Core {
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
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.
if ( class_exists( 'TMDO_Audit_Logger' ) ) {
TMDO_Audit_Logger::init();
+8 -3
View File
@@ -1396,8 +1396,13 @@ class TMDO_REST_API {
): WP_REST_Response {
global $wpdb;
// Filters and ordering only on public columns: hidden values must not be
// inferable by filtering or sorting on them.
$table = TMDO_Zone_Hot::table( $post_type );
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
$columns = array_intersect_key(
TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ),
array_flip( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) )
);
// Build WHERE clauses from numeric filter params.
$where = array();
@@ -1443,8 +1448,8 @@ class TMDO_REST_API {
'post_type' => $post_type,
);
unset( $row['post_id'], $row['updated_at'] );
// Keep only columns with show_in_rest => true (or all if no registry entry).
$row = $visible_cols ? array_intersect_key( $row, $visible_cols ) : $row;
// Keep only public columns (none when nothing is declared public).
$row = array_intersect_key( $row, $visible_cols );
return array_merge( $item, $row );
},
$rows
+53 -4
View File
@@ -67,6 +67,13 @@ class TMDO_Schema_Registry {
*/
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.
*/
@@ -96,7 +103,9 @@ class TMDO_Schema_Registry {
* - ttl (int) TTL in seconds (Zone B only, null = no expiry).
* - cache_group (string) Object cache group (Zone C only).
* - cache_ttl (int) Cache TTL in seconds (Zone C only).
* - show_in_rest (bool) Whether to expose via GET /wpdo/v1/listings. Default true.
* - show_in_rest (bool) Whether to expose via the public GET /wpdo/v1/listings
* endpoints. Default false; protected (underscore) keys and
* credential-like keys are never exposed (see rest_exposable()).
*/
public function register( string $provider, array $config ): void {
$config = wp_parse_args(
@@ -113,7 +122,7 @@ class TMDO_Schema_Registry {
'cache_group' => '',
'cache_ttl' => HOUR_IN_SECONDS,
'provider' => $provider,
'show_in_rest' => true,
'show_in_rest' => false,
)
);
@@ -138,6 +147,7 @@ class TMDO_Schema_Registry {
switch ( $config['zone'] ) {
case 'hot':
$this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type'];
$this->flag_hot_deprecation( $provider, $config['meta_key'] );
break;
case 'warm':
@@ -159,6 +169,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.
*
@@ -245,6 +278,22 @@ class TMDO_Schema_Registry {
return array_unique( $this->cold_fields[ $post_type ] ?? array() );
}
/**
* Whether a field may appear in the public listings endpoints: declared
* show_in_rest, not a protected meta key (leading underscore — WordPress
* hides those from public output, e.g. _purchase_note), and not named like a
* credential (tokens, secrets, passwords, API keys), whatever it declares.
*
* @param array<string, mixed> $field Registered field.
* @return bool
*/
private static function rest_exposable( array $field ): bool {
$key = (string) ( $field['meta_key'] ?? '' );
return ! empty( $field['show_in_rest'] )
&& 0 !== strpos( $key, '_' )
&& 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) );
}
/**
* Returns hot column names for a post type where show_in_rest is true.
* Used by REST endpoints to restrict publicly exposed fields.
@@ -255,7 +304,7 @@ class TMDO_Schema_Registry {
public function get_rest_visible_hot_columns( string $post_type ): array {
$visible = array();
foreach ( $this->fields as $field ) {
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
$visible[] = $field['column'];
}
}
@@ -272,7 +321,7 @@ class TMDO_Schema_Registry {
public function get_rest_visible_cold_keys( string $post_type ): array {
$visible = array();
foreach ( $this->fields as $field ) {
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
$visible[] = $field['meta_key'];
}
}
@@ -442,7 +442,7 @@ class TMDO_Site_Health {
* Not cached: the collision list is in-memory state rebuilt on every
* request, so reading it is free and a transient would only go stale.
*
* @return array Site Health result.
* @return array<string, mixed> Site Health result.
*/
public static function check_entity_group_collisions(): array {
$collisions = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_collisions() : array();
@@ -482,7 +482,7 @@ class TMDO_Site_Health {
*
* Not cached for the same reason as check_entity_group_collisions().
*
* @return array Site Health result.
* @return array<string, mixed> Site Health result.
*/
public static function check_orphaned_entity_groups(): array {
$restored = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_restored_groups() : array();
@@ -521,7 +521,7 @@ class TMDO_Site_Health {
*
* Not cached: two class_exists() probes.
*
* @return array Site Health result.
* @return array<string, mixed> Site Health result.
*/
public static function check_missing_integration_addons(): array {
$missing = class_exists( 'TMDO_Compatibility' ) ? TMDO_Compatibility::missing_integration_addons() : array();
+34 -5
View File
@@ -31,19 +31,22 @@ final class TMDO_Entity_Registry {
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
private static array $pending_schemas = array();
/** @var array<string, array<string, array>> 實體類型 → meta_key → 欄位定義(僅未限定 post_type 的群組)*/
/** @var array<string, array<string, array<string, mixed>>> 實體類型 → meta_key → 欄位定義(僅未限定 post_type 的群組)*/
private static array $unscoped_index = array();
/** @var array<string, array<string, array<string, array>>> 實體類型 → post_type → meta_key → 欄位定義 */
/** @var array<string, array<string, array<string, array<string, mixed>>>> 實體類型 → post_type → meta_key → 欄位定義 */
private static array $scoped_index = array();
/** @var array<string, array<string, string[]>> 實體類型 → 群組名 → 限定的 post_type */
private static array $scopes = array();
/** @var array<string, array> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
/** @var array<string, array<string, array<string, string[]>>> 實體類型 → 群組名 → 索引名 → 欄位 key */
private static array $indexes = array();
/** @var array<string, array<string, mixed>> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
private static array $collisions = array();
/** @var array<string, array> "entity/group" → 從 wpdo_registry_meta 還原的孤兒群組 */
/** @var array<string, array<string, mixed>> "entity/group" → 從 wpdo_registry_meta 還原的孤兒群組 */
private static array $restored = array();
/** 合法的欄位型別 */
@@ -74,6 +77,7 @@ final class TMDO_Entity_Registry {
self::$unscoped_index = array();
self::$scoped_index = array();
self::$scopes = array();
self::$indexes = array();
self::$pending_schemas = array();
self::$collisions = array();
self::$restored = array();
@@ -115,10 +119,13 @@ final class TMDO_Entity_Registry {
* - unique (bool) 唯一索引
* - options (array) enum 選項
* - label (string) 顯示名稱
* @param array $args 選填:post_types (string[]) — 群組只套用於這些 post_type。
* @param array<string, mixed> $args 選填:post_types (string[]) — 群組只套用於這些 post_type。
* 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬;
* 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。
* 不列入欄位定義,所以不影響 schema hash 與衝突比對。
* indexes (array<string, string[]>) — 複合索引:名稱 → 欄位 key(依序);
* 含未知欄位的索引會被略過。同樣不影響 schema hash,
* 由 TMDO_Schema_Manager::ensure_group_indexes() 補建到既有表。
*
* @return bool
*/
@@ -151,6 +158,13 @@ final class TMDO_Entity_Registry {
if ( $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 ) {
self::index_field( $entity_type, $normalized_field, $post_types );
}
@@ -169,6 +183,8 @@ final class TMDO_Entity_Registry {
/**
* 驗證並正規化欄位定義(略過缺 key/type 或型別不合法者)。
* @param array<int, array<string, mixed>> $fields
* @return array<int, array<string, mixed>>
*/
private static function normalize_fields( string $entity_type, string $group_name, array $fields ): array {
$normalized = array();
@@ -215,6 +231,7 @@ final class TMDO_Entity_Registry {
*
* @param string[] $native_keys 原生 meta 仍有資料、不可還原的 key。
* @return bool 有登錄任何欄位時為 true
* @param array<int, array<string, mixed>> $fields
*/
public static function restore_group( string $entity_type, string $group_name, array $fields, array $native_keys = array() ): bool {
if ( ! isset( self::$adapters[ $entity_type ] ) || isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
@@ -272,6 +289,7 @@ final class TMDO_Entity_Registry {
* 供不帶 post_type 的查詢使用;另外依是否限定 post_type 分別建索引。
*
* @param string[] $post_types 群組限定的 post_type;空陣列表示不限定。
* @param array<string, mixed> $field
*/
private static function index_field( string $entity_type, array $field, array $post_types ): void {
self::$field_index[ $entity_type ][ $field['key'] ] = $field;
@@ -298,6 +316,8 @@ final class TMDO_Entity_Registry {
* 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。
*
* 註冊每個請求都會跑,warning 以 transient 節流為每天一次,避免灌爆 wpdo_errors。
* @param array<int, array<string, mixed>> $existing
* @param array<int, array<string, mixed>> $incoming
*/
private static function record_collision( string $entity_type, string $group_name, array $existing, array $incoming ): void {
$id = $entity_type . '/' . $group_name;
@@ -372,6 +392,15 @@ final class TMDO_Entity_Registry {
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(未限定時為空陣列)。
*
+363 -44
View File
@@ -44,6 +44,15 @@ final class TMDO_Hook_Bus {
// ── 刪除攔截 ──────────────────────────────────────────
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
// ── dual_write / shadow_read:原生寫入完成後,flat 從原生同步 ──
// Native is the source of truth in these modes; flat follows what
// native actually became (WP may skip or refuse a write).
add_action( "added_{$type}_meta", array( self::class, 'on_meta_added' ), 1, 4 );
add_action( "updated_{$type}_meta", array( self::class, 'on_meta_updated' ), 1, 4 );
add_action( "delete_{$type}_meta", array( self::class, 'before_meta_deleted' ), 1, 4 );
add_action( "deleted_{$type}_meta", array( self::class, 'on_meta_deleted' ), 1, 4 );
add_filter( "update_{$type}_metadata_by_mid", array( self::class, 'before_update_by_mid' ), 1, 4 );
// ── 實體刪除時自動清理 ───────────────────────────────
add_action(
$adapter->get_delete_hook(),
@@ -70,13 +79,13 @@ final class TMDO_Hook_Bus {
*/
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
return $check;
}
@@ -87,8 +96,8 @@ final class TMDO_Hook_Bus {
// ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
// dual_write: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
// shadow_read: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
// dual_write : 放行 WP 寫 EAV;updated_/added_ action 再從原生同步 flat
// shadow_read: 同 dual_write
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
return $check; // disabled
@@ -105,6 +114,13 @@ final class TMDO_Hook_Bus {
return true;
}
// dual_write / shadow_read: native first. Writing flat here, before WP
// decides whether native changes at all (same value, non-matching
// prev_value), let flat drift; sync_from_native() runs after the write.
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
// 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' );
@@ -113,23 +129,18 @@ final class TMDO_Hook_Bus {
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
// 若 mode 也要寫 EAV → return null 讓 WP 繼續
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null; // dual_write / shadow_read
}
return $flat_result; // aeav_only
}
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
return $check;
}
@@ -150,6 +161,12 @@ final class TMDO_Hook_Bus {
return true;
}
// dual_write / shadow_read: native first (add($unique) may be refused);
// sync_from_native() runs on added_{type}_meta.
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
// UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' );
@@ -158,11 +175,228 @@ final class TMDO_Hook_Bus {
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
return $flat_result;
}
return $flat_result;
// ─────────────────────────────────────────────────────────
// dual_write / shadow_read:原生寫入後同步 flat
// ─────────────────────────────────────────────────────────
/**
* Entity IDs whose native rows a pending delete removes, keyed "type:key".
*
* @var array<string, int[]>
*/
private static array $pending_deletes = array();
/**
* Old keys of rows renamed through update_metadata_by_mid, keyed by meta ID.
*
* @var array<string, array{type:string, id:int, key:string}>
*/
private static array $pending_renames = array();
/**
* Copy the entity's current native value for a key into its flat column
* (NULL when native has no row). Runs in dual_write / shadow_read only,
* after WP wrote native, so flat always ends up equal to native — also
* when WP skipped or refused the write, and when an earlier direct SQL
* write had left flat stale. A NULL never creates a row: deleting an
* entity drops its flat row before WP deletes the meta rows one by one.
*
* @param string $type Entity type.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Value the caller wrote (for listeners).
* @param string $op 'add' | 'update' | 'delete'.
* @param bool $delete_all Whether the delete was a delete_all.
* @return void
*/
public static function sync_from_native( string $type, int $object_id, string $meta_key, $meta_value, string $op, bool $delete_all = false ): void {
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return;
}
$field_def = self::field_for( $type, $object_id, $meta_key );
if ( ! $field_def || 'flat' !== self::decide_route( $op, $type, $object_id, $meta_key, $meta_value ) ) {
return;
}
$before = self::maybe_read_before_value( $type, $object_id, $meta_key, $field_def, $op );
$native = self::native_first_value( $type, $object_id, $meta_key );
$result = null === $native
? self::clear_column( $type, $object_id, $meta_key, $field_def )
: self::perform_upsert( $type, $object_id, $meta_key, maybe_unserialize( $native ), $field_def );
if ( 'delete' === $op ) {
do_action( 'wpdo_after_delete', $type, $object_id, $meta_key, $meta_value, $result, $delete_all, $before );
} else {
do_action( 'wpdo_after_write', $type, $object_id, $meta_key, $meta_value, $result, $op, $before );
}
}
/**
* `added_{type}_meta` action.
*
* @param int $meta_id Meta ID.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function on_meta_added( $meta_id, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( $type ) {
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'add' );
}
}
/**
* `updated_{type}_meta` action; also re-syncs the old key of a row renamed
* through update_metadata_by_mid.
*
* @param int $meta_id Meta ID.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function on_meta_updated( $meta_id, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( ! $type ) {
return;
}
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'update' );
$rename_key = $type . ':' . (int) $meta_id;
if ( isset( self::$pending_renames[ $rename_key ] ) ) {
$old = self::$pending_renames[ $rename_key ];
unset( self::$pending_renames[ $rename_key ] );
self::sync_from_native( $type, $old['id'], $old['key'], null, 'delete' );
}
}
/**
* `update_{type}_metadata_by_mid` filter: remember the old key when a row
* is renamed, so its flat column can follow. Never changes $check.
*
* @param mixed $check Short-circuit value.
* @param int $meta_id Meta ID.
* @param mixed $meta_value New value.
* @param string|false $meta_key New key, or false to keep it.
* @return mixed
*/
public static function before_update_by_mid( $check, $meta_id, $meta_value, $meta_key ) {
$type = self::resolve_type_from_meta_hook();
if ( ! $type || false === $meta_key ) {
return $check;
}
$row = self::native_row_by_mid( $type, (int) $meta_id );
if ( $row && $row['key'] !== $meta_key && TMDO_Entity_Registry::get_field( $type, $row['key'] ) ) {
self::$pending_renames[ $type . ':' . (int) $meta_id ] = array(
'type' => $type,
'id' => $row['id'],
'key' => $row['key'],
);
}
return $check;
}
/**
* `delete_{type}_meta` action (rows still exist): record which entities a
* delete touches — a delete_all passes object ID 0.
*
* @param int[] $meta_ids Meta IDs to delete.
* @param int $object_id Entity ID (0 for delete_all).
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function before_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( ! $type || ! TMDO_Entity_Registry::get_field( $type, (string) $meta_key ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return;
}
$ids = array();
foreach ( (array) $meta_ids as $mid ) {
$row = self::native_row_by_mid( $type, (int) $mid );
if ( $row ) {
$ids[ $row['id'] ] = $row['id'];
}
}
self::$pending_deletes[ $type . ':' . $meta_key ] = array_values( $ids );
}
/**
* `deleted_{type}_meta` action: re-sync every entity the delete touched.
*
* @param int[] $meta_ids Deleted meta IDs.
* @param int $object_id Entity ID (0 for delete_all).
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function on_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( ! $type ) {
return;
}
$pending_key = $type . ':' . $meta_key;
$ids = self::$pending_deletes[ $pending_key ] ?? array( (int) $object_id );
unset( self::$pending_deletes[ $pending_key ] );
foreach ( $ids as $id ) {
if ( $id > 0 ) {
self::sync_from_native( $type, (int) $id, (string) $meta_key, $meta_value, 'delete', 0 === (int) $object_id );
}
}
}
/**
* First native value of a key (the one get_*_meta( $id, $key, true ) returns),
* read with SQL so no meta filter runs. Null when the entity has no row.
*
* @param string $type Entity type.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @return string|null
*/
private static function native_first_value( string $type, int $object_id, string $meta_key ): ?string {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
// get_results(): get_var() turns an empty string into null.
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$pk}` LIMIT 1", $object_id, $meta_key ),
ARRAY_A
);
return $rows ? (string) $rows[0]['meta_value'] : null;
}
/**
* Entity ID and key of a native meta row.
*
* @param string $type Entity type.
* @param int $meta_id Meta ID.
* @return array{id:int, key:string}|null
*/
private static function native_row_by_mid( string $type, int $meta_id ): ?array {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
$row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key AS mkey FROM `{$table}` WHERE `{$pk}` = %d", $meta_id ), ARRAY_A );
if ( ! $row ) {
return null;
}
return array(
'id' => (int) $row['id'],
'key' => (string) $row['mkey'],
);
}
/**
@@ -291,6 +525,34 @@ final class TMDO_Hook_Bus {
return 'flat';
}
/**
* Set a flat column to NULL on the entity's existing row; never insert one.
*
* @param string $type Entity type.
* @param int $entity_id Entity ID.
* @param string $meta_key Meta key.
* @param array<string, mixed> $field_def Registered field.
* @return bool|null Null when there is no adapter or table.
*/
private static function clear_column( string $type, int $entity_id, string $meta_key, array $field_def ): ?bool {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
$table = TMDO_Schema_Manager::get_table_name( $type, $field_def['group'] );
if ( ! $adapter || ! TMDO_Schema_Manager::table_exists( $table ) ) {
return null;
}
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $wpdb->update( $table, array( $col => null ), array( $adapter->get_entity_id_column() => $entity_id ) );
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $field_def['group'] );
self::touch_posts_last_changed( $type );
return false !== $result;
}
/**
* 執行 Upsert 操作
*/
@@ -322,25 +584,31 @@ final class TMDO_Hook_Bus {
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
// 鎖防遞迴
$lock_key = $entity_id . ':' . $meta_key;
// 鎖防遞迴(含 entity type:user 5 與 post 5 不可互鎖)
$lock_key = $type . ':' . $entity_id . ':' . $meta_key;
self::$internal_ops[ $lock_key ] = true;
try {
// 單一 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(
$table,
array_merge(
array(
$id_col => $entity_id,
$col => $db_value,
),
$extra
),
array( $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 );
self::touch_posts_last_changed( $type );
// 短路回傳 true(WP 認為寫入成功)
return $result !== false;
@@ -382,12 +650,20 @@ final class TMDO_Hook_Bus {
// ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff
// shadow_read : 讀取仍走 EAV,抽樣與 flat 比對記錄 diff
// aeav_only : 讀取走 UAE flat,不讀 EAV
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
return $check; // disabled / dual_write
}
// shadow_read serves native: update_metadata() reads the old value
// through this filter, and serving the just-upserted flat value made
// WP see old === new and skip the native write (dev36, 2026-09-26).
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
self::maybe_shadow_compare( $type, (int) $object_id, $meta_key, $field_def );
return $check;
}
$group = $field_def['group'];
$row = self::get_or_load_row( $type, (int) $object_id, $group );
@@ -395,22 +671,6 @@ final class TMDO_Hook_Bus {
$has_value = is_array( $row ) && array_key_exists( $col, $row );
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
// Shadow-read:與 EAV 比對,記錄差異
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
try {
TMDO_Shadow_Diff_Logger::compare_and_log(
$type,
(int) $object_id,
$meta_key,
$value,
$field_def
);
} catch ( \Throwable $e ) {
// 比對失敗不該影響讀取
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
}
}
// 值為空 → 回 WP 原生慣例
if ( empty( $row ) || $value === null || $value === '' ) {
return $single ? '' : array();
@@ -420,6 +680,36 @@ final class TMDO_Hook_Bus {
return $single ? array( $value ) : array( $value );
}
/**
* shadow_read: compare the flat value with native meta for a sample of reads.
*
* Each compare costs a flat row read and a native SQL read, so only a share
* of reads is checked (filter `tmdo_shadow_compare_sample_rate`, 0..1,
* default 0.1). The logger rate-limits repeated diffs itself.
*
* @param string $type Entity type.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param array<string, mixed> $field_def Field definition.
*/
private static function maybe_shadow_compare( string $type, int $object_id, string $meta_key, array $field_def ): void {
$rate = (float) apply_filters( 'tmdo_shadow_compare_sample_rate', 0.1, $type, $meta_key );
if ( $rate <= 0 || ( $rate < 1 && wp_rand( 1, 1000 ) > $rate * 1000 ) ) {
return;
}
$row = self::get_or_load_row( $type, $object_id, $field_def['group'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
$value = array_key_exists( $col, $row ) ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
try {
TMDO_Shadow_Diff_Logger::compare_and_log( $type, $object_id, $meta_key, $value, $field_def );
} catch ( \Throwable $e ) {
// 比對失敗不該影響讀取
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
}
}
/**
* 取得(或載入)完整列資料,並快取
*/
@@ -489,6 +779,12 @@ final class TMDO_Hook_Bus {
return true;
}
// dual_write / shadow_read: WP deletes native (also delete_all);
// deleted_{type}_meta re-syncs every touched entity from native.
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
// 截取 before value(v1.3.1)
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' );
@@ -569,14 +865,11 @@ final class TMDO_Hook_Bus {
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 );
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
return $result !== false;
return $result !== false; // aeav_only
}
// ─────────────────────────────────────────────────────────
@@ -608,6 +901,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 名稱推斷實體類型
* 例:update_user_metadata → user
@@ -622,10 +929,22 @@ final class TMDO_Hook_Bus {
return null;
}
/**
* Entity type from the meta action / by-mid filter being run
* (added_user_meta, deleted_post_meta, update_term_metadata_by_mid, …).
*/
private static function resolve_type_from_meta_hook(): ?string {
if ( preg_match( '/^(?:(?:added|updated|delete|deleted)_(post|user|term|comment)_meta|update_(post|user|term|comment)_metadata_by_mid)$/', current_filter(), $matches ) ) {
return '' !== $matches[1] ? $matches[1] : $matches[2];
}
return null;
}
/**
* 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。
*
* 先做不帶 post_type 的查詢:大多數 key 不受管理,可省下 get_post_type()。
* @return array<string, mixed>|null
*/
private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
@@ -140,6 +140,11 @@ final class TMDO_Mode_Manager {
);
}
$guarded = TMDO_Promotion_Guard::check_promotion( $entity_type, $new_mode );
if ( $guarded ) {
return $guarded;
}
$all = self::all();
$all[ $entity_type ] = $new_mode;
@@ -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 );
}
}
@@ -0,0 +1,140 @@
<?php
/**
* TMDO_Promotion_Guard — keeps native meta authoritative on HivePress /
* WooCommerce sites (ADR-004 "search-index mode").
*
* Both plugins read native meta without going through a keyed
* get_*_meta() call: HivePress models load every key with get_*_meta( $id ),
* WooCommerce does the same for products and writes stock / sales with direct
* SQL. The Hook Bus cannot serve those reads from flat tables, so on these
* sites an entity must never reach aeav_only and native rows must never be
* cleaned up. Demotion always stays open.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Refuses aeav_only promotion and native-meta cleanup where an integration
* reads native meta directly.
*/
final class TMDO_Promotion_Guard {
/**
* Entity types each integration reads natively.
*
* HivePress models (Post, User, Term, Comment) all load meta with an empty
* key; WooCommerce does so for products and customers.
*/
private const GUARDED = array(
'HivePress' => array( 'post', 'user', 'term', 'comment' ),
'WooCommerce' => array( 'post', 'user' ),
);
/**
* Check a mode change. Only promotion to aeav_only is guarded.
*
* @param string $entity_type Entity type.
* @param string $new_mode Target mode.
* @return WP_Error|null Error when refused.
*/
public static function check_promotion( string $entity_type, string $new_mode ): ?WP_Error {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $new_mode ) {
return null;
}
return self::refuse( $entity_type, TMDO_Mode_Manager::MODE_AEAV_ONLY );
}
/**
* Check a cleanup of the entity's native managed meta rows.
*
* @param string $entity_type Entity type.
* @return WP_Error|null Error when refused.
*/
public static function check_cleanup( string $entity_type ): ?WP_Error {
return self::refuse( $entity_type, 'cleanup' );
}
/**
* Guarded entity types that are already in aeav_only (promoted before the
* guard existed). Native meta may be stale there.
*
* @return array<string, string[]> Entity type → integration names.
*/
public static function violations(): array {
$out = array();
foreach ( TMDO_Mode_Manager::all() as $type => $mode ) {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
continue;
}
$integrations = self::integrations_for( $type );
if ( ! empty( $integrations ) ) {
$out[ $type ] = $integrations;
}
}
return $out;
}
/**
* Active integrations that read this entity type natively.
*
* @param string $entity_type Entity type.
* @return string[]
*/
private static function integrations_for( string $entity_type ): array {
$active = array(
'HivePress' => TMDO_Compatibility::is_hivepress_active(),
'WooCommerce' => TMDO_Compatibility::is_woocommerce_active(),
);
$names = array();
foreach ( self::GUARDED as $name => $types ) {
if ( $active[ $name ] && in_array( $entity_type, $types, true ) ) {
$names[] = $name;
}
}
return $names;
}
/**
* Build the refusal, unless no integration applies or a filter allows it.
*
* @param string $entity_type Entity type.
* @param string $action 'aeav_only' or 'cleanup'.
* @return WP_Error|null
*/
private static function refuse( string $entity_type, string $action ): ?WP_Error {
$integrations = self::integrations_for( $entity_type );
if ( empty( $integrations ) ) {
return null;
}
/**
* Allow a guarded aeav_only promotion or cleanup anyway.
*
* @param bool $allow Default false.
* @param string $entity_type Entity type.
* @param string $action 'aeav_only' or 'cleanup'.
* @param string[] $integrations Integrations that read this entity natively.
*/
if ( apply_filters( 'tmdo_promotion_guard_allow', false, $entity_type, $action, $integrations ) ) {
return null;
}
return new WP_Error(
'promotion_guarded',
sprintf(
/* translators: 1: integration names, 2: entity type, 3: aeav_only or cleanup */
__( '%1$s 直接讀取原生 meta,%2$s 不能執行 %3$s:這類站台以原生 meta 為唯一資料來源(搜尋索引模式)。', '2meet-data-optimizer' ),
implode( ' / ', $integrations ),
$entity_type,
$action
)
);
}
}
@@ -20,6 +20,9 @@ defined( 'ABSPATH' ) || exit;
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. */
private static array $table_exists_cache = array();
@@ -60,6 +63,7 @@ final class TMDO_Schema_Manager {
$schema['group'],
$schema['fields']
);
self::ensure_group_indexes( $schema['type'], $schema['group'] );
}
TMDO_Entity_Registry::clear_pending_schemas();
@@ -164,6 +168,50 @@ final class TMDO_Schema_Manager {
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(用於偵測欄位變動)
*/
+35 -3
View File
@@ -70,10 +70,12 @@ final class TMDO_Type_Caster {
return $value;
case 'text':
return (string) $value;
return self::fit_text( is_scalar( $value ) ? (string) $value : maybe_serialize( $value ), $field_def );
case 'textarea':
return (string) $value;
// Arrays / objects are serialized like WP stores them in native meta;
// (string) would store the literal "Array".
return is_scalar( $value ) ? (string) $value : maybe_serialize( $value );
default:
// 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為)
@@ -81,6 +83,33 @@ final class TMDO_Type_Caster {
}
}
/** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */
private const TEXT_MAX_CHARS = 255;
/**
* Keep a text value only if it fits its VARCHAR(255) column; otherwise
* store NULL (native meta keeps the full value) and log the overflow.
*
* @param string $value Value to store.
* @param array<string, mixed> $field_def Field definition.
* @return string|null
*/
private static function fit_text( string $value, array $field_def ): ?string {
$length = mb_strlen( $value );
if ( $length <= self::TEXT_MAX_CHARS ) {
return $value;
}
TMDO_Logger::warning(
'text_overflow',
array(
'group' => $field_def['group'] ?? '',
'key' => $field_def['key'] ?? '',
'length' => $length,
)
);
return null;
}
/**
* DB → PHP 轉換(讀取)
*/
@@ -111,9 +140,12 @@ final class TMDO_Type_Caster {
return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value;
case 'enum':
return (string) $value;
case 'text':
case 'textarea':
return (string) $value;
// Arrays were stored serialized (to_db); plain strings pass through.
return TMDO_Safe_Unserialize::run( (string) $value );
default:
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
@@ -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:
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
* activity → wp_wpdo_user_activity (login counters, last-active — high-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.
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
@@ -61,7 +61,6 @@ final class TMDO_Member_Fields {
self::register_membership_group();
self::register_activity_group();
self::register_profile_group();
self::register_sso_group();
self::register_core_profile_group();
self::register_social_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.
*
@@ -0,0 +1,111 @@
<?php
/**
* TMDO_Array_Literal_Repair — finds flat text / textarea values stored as the
* literal "Array" (pre-1.1.0 type caster cast arrays with (string)) and
* rewrites them from native meta.
*
* Native rows that are themselves "Array" (written back by an earlier
* bridge-restore-native) cannot be repaired here: they are only listed, to be
* restored from a backup.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery -- table / column names come from the registry and adapter; values are prepared.
/**
* Repairs "Array" literals in flat text / textarea columns.
*/
final class TMDO_Array_Literal_Repair {
/** What (string) array produced. */
private const LITERAL = 'Array';
/**
* Scan (and optionally repair) one entity type.
*
* @param string $entity_type Entity type.
* @param bool $apply Write repairs; false = report only.
* @return array{found:int, repairable:int, repaired:int, unrepairable:array<int, array{group:string, key:string, id:int, reason:string}>, native_polluted:array<int, array{group:string, key:string, id:int}>}
*/
public static function run( string $entity_type, bool $apply ): array {
global $wpdb;
$report = array(
'found' => 0,
'repairable' => 0,
'repaired' => 0,
'unrepairable' => array(),
'native_polluted' => array(),
);
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return $report;
}
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$meta_pk = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
if ( ! in_array( $field['type'] ?? 'text', array( 'text', 'textarea' ), true ) ) {
continue;
}
$key = (string) $field['key'];
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$ids = $wpdb->get_col( $wpdb->prepare( "SELECT `{$id_col}` FROM `{$table}` WHERE `{$col}` = %s", self::LITERAL ) );
foreach ( $ids as $id ) {
$id = (int) $id;
++$report['found'];
$native = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$meta_table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$meta_pk}` LIMIT 1", $id, $key ) );
$reason = null === $native ? 'no_native_row' : ( self::LITERAL === $native ? 'native_is_array_literal' : '' );
if ( '' !== $reason ) {
$report['unrepairable'][] = array(
'group' => $group,
'key' => $key,
'id' => $id,
'reason' => $reason,
);
continue;
}
++$report['repairable'];
if ( $apply ) {
$value = TMDO_Type_Caster::to_db( TMDO_Safe_Unserialize::run( $native ), $field + array( 'group' => $group ) );
$wpdb->update( $table, array( $col => $value ), array( $id_col => $id ) );
++$report['repaired'];
}
}
$polluted = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT `{$id_col}` FROM `{$meta_table}` WHERE meta_key = %s AND meta_value = %s", $key, self::LITERAL ) );
foreach ( $polluted as $id ) {
$report['native_polluted'][] = array(
'group' => $group,
'key' => $key,
'id' => (int) $id,
);
}
}
}
if ( $report['repaired'] > 0 ) {
TMDO_Cache_Orchestrator::flush_entity( $entity_type );
}
return $report;
}
}
+251 -22
View File
@@ -14,12 +14,26 @@
* the value Type_Caster returns); a NULL value deletes the native row.
* - Entities with no row in the owning group's table are not touched:
* leftover native rows for them are outside the flat data.
* - A flat value equal to its field default is not written for an entity
* that has no native row (e.g. activity.session_count = 0): that would
* invent native meta the entity never had.
*
* Policy (which side wins where both hold a value):
* - overwrite — flat wins; the default in aeav_only, where writes only
* reached the flat tables and native is stale.
* - only_missing — native wins, flat only fills gaps; the default in every
* other mode, where native is the source of truth (and may
* be newer, e.g. WooCommerce stock written with direct SQL).
*
* Batched by entity id with a cursor option so an interrupted run resumes; a
* final pass re-copies rows updated since the first run started. With
* $demote the entity then moves to dual_write, which keeps both copies in
* 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
*/
@@ -47,14 +61,20 @@ final class TMDO_Native_Restorer {
* @param bool $dry_run Count only; write nothing.
* @param int $batch Rows per batch.
* @param bool $demote After a complete run, switch the entity to dual_write.
* @return array{groups: array<string, array{rows:int, written:int}>, demoted: bool}
* @param string $group Only this group ('' = every group).
* @param string $policy 'overwrite' | 'only_missing' | '' (by the entity's mode).
* @return array{groups: array<string, array{rows:int, written:int, missing:int, differs:int, same:int, default_skipped:int}>, demoted: bool, policy: string}
*/
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 = '', string $policy = '' ): array {
global $wpdb;
if ( ! in_array( $policy, array( 'overwrite', 'only_missing' ), true ) ) {
$policy = TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ? 'overwrite' : 'only_missing';
}
$report = array(
'groups' => array(),
'demoted' => false,
'policy' => $policy,
);
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
@@ -73,10 +93,17 @@ final class TMDO_Native_Restorer {
$cursor = is_array( $cursor ) ? $cursor : array();
$tables = self::group_tables( $entity_type );
if ( '' !== $group ) {
$tables = array_intersect_key( $tables, array( $group => true ) );
}
foreach ( $tables as $group => $table ) {
$entry = array(
'rows' => 0,
'written' => 0,
'missing' => 0,
'differs' => 0,
'same' => 0,
'default_skipped' => 0,
);
$key = "{$entity_type}/{$group}";
$last = $dry_run ? 0 : (int) ( $cursor[ $key ] ?? 0 );
@@ -86,8 +113,10 @@ final class TMDO_Native_Restorer {
$entry['rows'] += $fetched;
if ( $fetched ) {
$last = (int) end( $rows )[ $id_col ];
foreach ( self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, ! $dry_run ) as $count_key => $n ) {
$entry[ $count_key ] += $n;
}
if ( ! $dry_run ) {
$entry['written'] += self::write_rows( $entity_type, $group, $adapter, $rows );
$cursor[ $key ] = $last;
update_option( self::CURSOR_OPTION, $cursor, false );
}
@@ -104,7 +133,7 @@ final class TMDO_Native_Restorer {
// Catch-up: writes that landed in the flat tables after a batch passed their row.
foreach ( $tables as $group => $table ) {
$rows = self::fetch( $entity_type, $table, $id_col, 't.updated_at >= %s', array( $started[ $entity_type ] ) );
self::write_rows( $entity_type, $group, $adapter, $rows );
self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, true );
}
foreach ( array_keys( $tables ) as $group ) {
@@ -121,6 +150,75 @@ final class TMDO_Native_Restorer {
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.
*
@@ -145,7 +243,7 @@ final class TMDO_Native_Restorer {
* @param string $table Flat table.
* @param string $id_col Entity id column.
* @param string $where WHERE clause with placeholders.
* @param array $params Placeholder values.
* @param array<int, mixed> $params Placeholder values.
* @return array<int, array<string, mixed>>
*/
private static function fetch( string $entity_type, string $table, string $id_col, string $where, array $params ): array {
@@ -158,24 +256,87 @@ final class TMDO_Native_Restorer {
}
/**
* Replace the native meta of the keys this group owns for each row.
* Compare the native meta of the keys this group owns with the flat rows,
* and (when $write) bring native in line according to $policy.
*
* @param string $entity_type Entity type.
* @param string $group Group the rows come from.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param array $rows Flat rows.
* @return int Native values written.
* @param array<int, array<string, mixed>> $rows Flat rows.
* @param string $policy 'overwrite' | 'only_missing'.
* @param bool $write False = classify only (dry run).
* @return array{written:int, missing:int, differs:int, same:int, default_skipped:int}
*/
private static function write_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): int {
global $wpdb;
if ( empty( $rows ) ) {
return 0;
private static function apply_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows, string $policy, bool $write ): array {
$counts = array(
'written' => 0,
'missing' => 0,
'differs' => 0,
'same' => 0,
'default_skipped' => 0,
);
list( $wanted, $fields ) = self::flat_values( $entity_type, $group, $adapter, $rows );
if ( empty( $wanted ) ) {
return $counts;
}
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$native = self::native_values( $entity_type, $adapter, array_keys( $wanted ), array_keys( $fields ) );
$deletes = array();
$inserts = array();
foreach ( $wanted as $id => $values ) {
foreach ( $values as $key => $value ) {
$has = isset( $native[ $id ] ) && array_key_exists( $key, $native[ $id ] );
if ( null === $value ) {
if ( $has ) {
++$counts['differs'];
if ( 'overwrite' === $policy ) {
$deletes[ $key ][] = $id;
}
}
continue;
}
if ( ! $has ) {
if ( self::is_default( $value, $fields[ $key ] ) ) {
++$counts['default_skipped'];
continue;
}
++$counts['missing'];
$inserts[] = array( $id, $key, $value );
continue;
}
if ( self::same_value( $native[ $id ][ $key ], $value ) ) {
++$counts['same'];
continue;
}
++$counts['differs'];
if ( 'overwrite' === $policy ) {
$deletes[ $key ][] = $id;
$inserts[] = array( $id, $key, $value );
}
}
}
if ( $write ) {
self::write_native( $entity_type, $adapter, $deletes, $inserts, array_keys( $wanted ) );
$counts['written'] = count( $inserts );
}
return $counts;
}
/**
* Values this group would write to native, per entity and key (NULL = none),
* restricted to the keys the group owns for that entity.
*
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param array<int, array<string, mixed>> $rows Flat rows.
* @return array{0: array<int, array<string, string|null>>, 1: array<string, array<string, mixed>>}
*/
private static function flat_values( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): array {
$id_col = $adapter->get_entity_id_column();
$wanted = array();
$fields = array();
foreach ( $rows as $row ) {
$id = (int) $row[ $id_col ];
$post_type = 'post' === $entity_type ? (string) ( $row['__post_type'] ?? '' ) : '';
@@ -185,25 +346,93 @@ final class TMDO_Native_Restorer {
if ( null === $owner || $owner['group'] !== $group || ! array_key_exists( $col, $row ) ) {
continue;
}
$deletes[ $field['key'] ][] = $id;
if ( null !== $row[ $col ] ) {
$inserts[] = array( $id, $field['key'], (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) ) );
$fields[ $field['key'] ] = $field;
$wanted[ $id ][ $field['key'] ] = null === $row[ $col ]
? null
: (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) );
}
}
return array( $wanted, $fields );
}
foreach ( $deletes as $meta_key => $ids ) {
$in = implode( ',', array_map( 'intval', $ids ) );
/**
* First native value per entity and key.
*
* @param string $entity_type Entity type.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param int[] $ids Entity IDs.
* @param string[] $keys Meta keys.
* @return array<int, array<string, string>>
*/
private static function native_values( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $ids, array $keys ): array {
global $wpdb;
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$pk = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
$in_ids = implode( ',', array_map( 'intval', $ids ) );
$in_keys = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$rows = (array) $wpdb->get_results(
$wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key, meta_value FROM `{$meta_table}` WHERE `{$id_col}` IN ({$in_ids}) AND meta_key IN ({$in_keys}) ORDER BY `{$pk}`", ...$keys ),
ARRAY_A
);
$native = array();
foreach ( $rows as $r ) {
if ( ! isset( $native[ (int) $r['id'] ][ $r['meta_key'] ] ) ) {
$native[ (int) $r['id'] ][ $r['meta_key'] ] = (string) $r['meta_value'];
}
}
return $native;
}
/**
* Delete / insert native rows and clear the entities' meta caches.
*
* @param string $entity_type Entity type.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param array<string, int[]> $deletes Meta key → entity IDs to clear first.
* @param array<int, array{0:int, 1:string, 2:string}> $inserts Rows to insert.
* @param int[] $ids Entities touched.
* @return void
*/
private static function write_native( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $deletes, array $inserts, array $ids ): void {
global $wpdb;
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
foreach ( $deletes as $meta_key => $del_ids ) {
$in = implode( ',', array_map( 'intval', $del_ids ) );
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$meta_table}` WHERE meta_key = %s AND `{$id_col}` IN ({$in})", $meta_key ) );
}
if ( $inserts ) {
$values = implode( ', ', array_fill( 0, count( $inserts ), '(%d, %s, %s)' ) );
$wpdb->query( $wpdb->prepare( "INSERT INTO `{$meta_table}` (`{$id_col}`, meta_key, meta_value) VALUES {$values}", ...array_merge( ...$inserts ) ) );
}
foreach ( array_unique( array_map( static fn( $row ) => (int) $row[ $id_col ], $rows ) ) as $id ) {
foreach ( $ids as $id ) {
wp_cache_delete( $id, "{$entity_type}_meta" );
}
return count( $inserts );
}
/**
* Whether a stored value (as WP would store it) equals the field default.
*
* @param string $value Value as it would be stored.
* @param array<string, mixed> $field Field definition.
* @return bool
*/
private static function is_default( string $value, array $field ): bool {
return isset( $field['default'] ) && (string) maybe_serialize( $field['default'] ) === $value;
}
/**
* Native and candidate values are equal (numeric-aware: '3' equals '3.000000').
*
* @param string $native Native meta value.
* @param string $candidate Value from the flat table.
* @return bool
*/
private static function same_value( string $native, string $candidate ): bool {
return $native === $candidate
|| ( is_numeric( $native ) && is_numeric( $candidate ) && (float) $native === (float) $candidate );
}
}
@@ -72,7 +72,7 @@ final class TMDO_Post_Group_Reconciler {
*
* @param bool $dry_run Count only; write nothing.
* @param int $batch Rows per batch.
* @return array{moves: array<int, array>, enabled: bool}
* @return array{moves: array<int, array<string, mixed>>, enabled: bool}
*/
public static function run( bool $dry_run = false, int $batch = 500 ): array {
global $wpdb;
@@ -165,9 +165,9 @@ final class TMDO_Post_Group_Reconciler {
/**
* FROM/WHERE for non-NULL legacy values of the move's post types.
*
* @param array $move One entry of plan().
* @param array<string, mixed> $move One entry of plan().
* @param string $id_col Entity id column of the flat tables.
* @return array{0:string, 1:array}
* @return array{0:string, 1:array<int, mixed>}
*/
private static function source_clause( array $move, string $id_col ): array {
global $wpdb;
@@ -184,9 +184,9 @@ final class TMDO_Post_Group_Reconciler {
/**
* Upsert one column of the target group rows and drop their cached copies.
*
* @param array $move One entry of plan().
* @param array<string, mixed> $move One entry of plan().
* @param string $id_col Entity id column of the flat tables.
* @param array $rows Source rows: list of { id, v }.
* @param array<int, array<string, mixed>> $rows Source rows: list of { id, v }.
* @return int Rows written.
*/
private static function copy_rows( array $move, string $id_col, array $rows ): int {
@@ -228,6 +228,10 @@ final class TMDO_Post_Migration {
'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only'
);
}
$guarded = TMDO_Promotion_Guard::check_cleanup( self::ENTITY_TYPE );
if ( $guarded ) {
throw new RuntimeException( esc_html( $guarded->get_error_message() ) );
}
global $wpdb;
$keys = self::get_managed_keys();
@@ -46,6 +46,10 @@ class TMDO_Phase_Cleanup extends TMDO_Migration_Phase_Base {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" );
}
$guarded = TMDO_Promotion_Guard::check_cleanup( $this->entity_type );
if ( $guarded ) {
throw new \RuntimeException( $guarded->get_error_message() );
}
if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) {
throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' );
}
+562 -69
View File
@@ -16,9 +16,23 @@
* Hook order:
* 1. pre_get_posts — strip registered keys from meta_query, store in
* $query->set( 'wpdo_post_clauses', $routed )
* 2. posts_join — LEFT JOIN wp_wpdo_post_<group> per routed post_type
* 2. posts_join — LEFT JOIN wp_wpdo_post_<group> per routed group
* 3. posts_where — append WHERE conditions targeting flat columns
* 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication
*
* Flat tables hold one row per post (UNIQUE post_id), so the joins never
* multiply rows and no GROUP BY is added.
*
* Status / date on the flat table (O-1): when the group's post_status /
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
* a single-post-type search with explicit statuses and date ordering
* INNER JOINs the table, filters the copies and orders by the copied
* post_date, so MySQL can walk `idx_post_status_date` and stop at the
* LIMIT. Not with NOT EXISTS: posts without a flat row must still match.
*
* Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every
* match inside the page query); found_posts_query answers from a COUNT of the
* 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
* only entity_type='post'.
@@ -43,16 +57,44 @@ class TMDO_Post_Query_Router {
/** Query var key for capturing routed clauses (consumed by posts_join/where). */
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 flat column a routed meta orderby sorts on. */
private const SORT_VAR = 'wpdo_post_sort';
/** Query var holding the COUNT for found_posts_query. */
private const COUNT_VAR = 'wpdo_post_count_sql';
/** Object cache group for found-rows counts. */
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
/** Comparison operators the router can express on a flat column. */
private const COMPARES = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' );
/**
* Per-request answer to "are this group's copies usable?".
*
* @var array<string, bool>
*/
private static array $copies_usable = array();
/**
* Register all WP_Query hooks.
*
* @return void
*/
public function register_hooks(): void {
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 );
// Late, so meta clauses other plugins add at the default priority
// (e.g. HivePress search filters and sorting) are already in the query.
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 1000, 1 );
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 );
add_filter( 'split_the_query', array( $this, 'split_the_query' ), 10, 4 );
add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 );
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
}
/**
@@ -72,60 +114,104 @@ class TMDO_Post_Query_Router {
return;
}
// One post type only: with several, a clause may belong to one type's
// group and the others must still match through wp_postmeta.
$post_types = (array) $query->get( 'post_type' );
if ( empty( $post_types ) ) {
if ( 1 !== count( $post_types ) ) {
return;
}
$pt = (string) reset( $post_types );
$raw_meta_query = (array) $query->get( 'meta_query' );
if ( empty( $raw_meta_query ) ) {
return;
}
// All or nothing: routed clauses are ANDed into the WHERE, so the
// query is routed only when every registered clause can be, and
// the remaining (unregistered) clauses are ANDed too.
$relation = strtoupper( (string) ( $raw_meta_query['relation'] ?? 'AND' ) );
$orderby = $query->get( 'orderby' );
$named = is_array( $orderby ) ? array_keys( $orderby ) : preg_split( '/[\s,]+/', trim( (string) $orderby ) );
$sort_name = 1 === count( $named ) ? (string) reset( $named ) : '';
$direction = 'ASC' === strtoupper( (string) ( is_array( $orderby ) ? reset( $orderby ) : $query->get( 'order' ) ) ) ? 'ASC' : 'DESC';
$routed = array(); // Keyed by post_type.
$remaining = array();
$sort = array();
// meta_key + orderby meta_value(_num): native INNER JOINs the key
// (EXISTS) and orders by meta_value+0 (NUMERIC) or meta_value.
$meta_key = $query->get( 'meta_key' );
$take_key = is_string( $meta_key ) && in_array( $sort_name, array( 'meta_value', 'meta_value_num' ), true )
&& null !== self::field_for( array( 'key' => $meta_key ), $pt );
if ( $take_key ) {
$field = self::field_for( array( 'key' => $meta_key ), $pt );
$type = 'meta_value_num' === $sort_name ? 'NUMERIC' : (string) $query->get( 'meta_type' );
$route = self::route_clause(
array(
'key' => $meta_key,
'compare' => 'EXISTS',
'type' => $type,
),
$field
);
if ( ! $route || ! self::sortable( $route, $field ) || '' !== $query->get( 'meta_value' ) || '' !== $query->get( 'meta_compare' ) ) {
return;
}
$routed[ $pt ][] = $route;
$sort = self::sort_for( $pt, $route, $direction );
}
foreach ( $raw_meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
if ( 'relation' === $k || ! is_array( $clause ) ) {
$remaining[ $k ] = $clause;
continue;
}
$matched = false;
foreach ( $post_types as $pt ) {
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], (string) $pt );
if ( ! $field ) {
continue;
}
// Confirm the field's group is appropriate for this post_type:
// either cross-cutting wp_core (any) or the post_type-specific group.
$group = $field['group'] ?? '';
$is_appropriate = ( 'wp_core' === $group ) || in_array( $group, TMDO_Post_Fields::groups_for_post_type( (string) $pt ), true );
if ( ! $is_appropriate ) {
continue;
}
$column = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] );
$routed[ $pt ][] = array(
'group' => $group,
'column' => $column,
'value' => $clause['value'] ?? '',
'compare' => strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) ),
'type' => strtoupper( trim( (string) ( $clause['type'] ?? 'CHAR' ) ) ),
);
$matched = true;
break;
}
if ( ! $matched ) {
$remaining[ $k ] = $clause;
}
}
if ( empty( $routed ) ) {
if ( ! isset( $clause['key'] ) ) {
// Sort shape: matches every post, only orders by the key.
$name = 'AND' === $relation ? self::sort_shape( $clause ) : '';
$field = '' !== $name && $name === $sort_name ? self::field_for( $clause[ $name ], $pt ) : null;
if ( $field ) {
$route = self::route_clause( array( 'compare' => 'EXISTS' ) + $clause[ $name ], $field );
if ( ! $route || ! self::sortable( $route, $field ) ) {
return;
}
$sort = self::sort_for( $pt, $route, $direction );
continue;
}
if ( self::has_registered_key( $clause, $pt ) ) {
return; // Nested group.
}
$remaining[ $k ] = $clause;
continue;
}
$field = self::field_for( $clause, $pt );
if ( ! $field ) {
$remaining[ $k ] = $clause;
continue;
}
$route = self::route_clause( $clause, $field );
if ( ! $route || 'AND' !== $relation ) {
return;
}
// Named by orderby: only as the sole ordering, on a column that
// orders like native (otherwise the name would be lost).
if ( is_string( $k ) && in_array( $k, $named, true ) ) {
if ( $k !== $sort_name || ! self::sortable( $route, $field ) ) {
return;
}
$sort = self::sort_for( $pt, $route, $direction );
}
$routed[ $pt ][] = $route;
}
if ( empty( $routed ) && empty( $sort ) ) {
return;
}
if ( $take_key ) {
$query->set( 'meta_key', '' );
}
if ( $sort ) {
$query->set( self::SORT_VAR, $sort );
}
// Preserve relation if surviving clauses still have it.
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
@@ -134,10 +220,15 @@ class TMDO_Post_Query_Router {
$query->set( 'meta_query', $remaining );
$query->set( self::QUERY_VAR, $routed );
$copy = self::copy_route( $query, $routed );
if ( $copy ) {
$query->set( self::COPY_VAR, $copy );
}
}
/**
* LEFT JOIN flat tables for each routed post_type.
* LEFT JOIN the flat table of each routed group.
*
* @param string|null $join Current JOIN SQL.
* @param \WP_Query $query Current WP_Query.
@@ -146,19 +237,20 @@ class TMDO_Post_Query_Router {
public function posts_join( ?string $join, \WP_Query $query ): string {
$join = (string) ( $join ?? '' );
$routed = $query->get( self::QUERY_VAR );
if ( empty( $routed ) || ! is_array( $routed ) ) {
return $join;
$sort = $query->get( self::SORT_VAR );
$pairs = array(); // Pairs of post type and group.
foreach ( is_array( $routed ) ? $routed : array() as $post_type => $clauses ) {
foreach ( array_column( $clauses, 'group' ) as $group ) {
$pairs[] = array( $post_type, $group );
}
}
if ( ! empty( $sort ) ) {
$pairs[] = array( $sort['post_type'], $sort['group'] );
}
global $wpdb;
foreach ( $routed as $post_type => $clauses ) {
// Each routed clause carries its group; use the first clause's group
// (all clauses for a given post_type currently target one group).
$group = $clauses[0]['group'] ?? '';
if ( '' === $group ) {
continue;
}
foreach ( $pairs as list( $post_type, $group ) ) {
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
$alias = self::table_alias( $post_type, $group );
@@ -167,7 +259,9 @@ class TMDO_Post_Query_Router {
continue;
}
$join .= " LEFT JOIN `{$table}` AS `{$alias}` "
// The copy route only applies to single-group searches.
$type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT';
$join .= " {$type} JOIN `{$table}` AS `{$alias}` "
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
}
@@ -201,29 +295,106 @@ 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;
}
/**
* Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
* Order by the routed meta orderby column (ties by ID), or by the flat
* table's post_date copy when the copy route applies.
*
* @param string|null $groupby Current GROUP BY SQL.
* @param string|null $orderby Current ORDER BY SQL.
* @param \WP_Query $query Current WP_Query.
* @return string Modified GROUP BY SQL.
* @return string Modified ORDER BY SQL.
*/
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
$groupby = (string) ( $groupby ?? '' );
$routed = $query->get( self::QUERY_VAR );
if ( empty( $routed ) || ! is_array( $routed ) ) {
return $groupby;
}
public function posts_orderby( ?string $orderby, \WP_Query $query ): string {
$orderby = (string) ( $orderby ?? '' );
$sort = $query->get( self::SORT_VAR );
if ( ! empty( $sort ) ) {
global $wpdb;
if ( '' === trim( $groupby ) ) {
$groupby = "`{$wpdb->posts}`.`ID`";
return '`' . self::table_alias( $sort['post_type'], $sort['group'] ) . '`.`' . esc_sql( $sort['column'] ) . '` '
. $sort['order'] . ", `{$wpdb->posts}`.`ID` DESC";
}
$copy = $query->get( self::COPY_VAR );
if ( empty( $copy ) ) {
return $orderby;
}
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
}
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 ?? '' );
$routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) );
if ( ! $routed ) {
return $request;
}
$counted = false !== strpos( $request, 'SQL_CALC_FOUND_ROWS' );
$request = self::routed_request( $request, $query );
if ( ! $counted ) {
return $request;
}
$matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
return $request;
}
/**
* Keep WordPress's split query (IDs first, then cached post objects) on a
* routed search: posts_request only dropped SQL_CALC_FOUND_ROWS, yet
* WordPress takes any change for a filtered request and selects full rows
* (a temporary table sorting whole posts). The split ID request passes
* through posts_request_ids, which drops SQL_CALC_FOUND_ROWS again.
*
* @param bool $split Core decision.
* @param \WP_Query $query Current WP_Query.
* @param string $old_request Request before posts_request.
* @param array<string, string> $clauses Query clauses (fields, limits …).
* @return bool
*/
public function split_the_query( $split, \WP_Query $query, $old_request = '', $clauses = array() ): bool {
$routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) );
if ( $split || ! $routed ) {
return (bool) $split;
}
global $wpdb;
// Core's other conditions: full rows, and a LIMIT under 500 or an external object cache.
return "{$wpdb->posts}.*" === ( $clauses['fields'] ?? '' )
&& self::routed_request( (string) $old_request, $query ) === $query->request
&& ( ( '' !== ( $clauses['limits'] ?? '' ) && (int) $query->get( 'posts_per_page' ) < 500 ) || wp_using_ext_object_cache() );
}
/**
* Answer found rows from the cached COUNT of a routed search.
*
* @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;
}
// ─────────────────────────────────────────────────────────────────────────
@@ -242,6 +413,325 @@ class TMDO_Post_Query_Router {
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<string, mixed>>> $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 )
|| in_array( 'NOT EXISTS', array_column( $routed[ $post_type ], 'compare' ), 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 ];
}
/**
* The registered field a clause targets for this post type, or null.
*
* @param array<string, mixed> $clause Meta query clause.
* @param string $post_type Post type slug.
* @return array<string, mixed>|null
*/
private static function field_for( array $clause, string $post_type ): ?array {
if ( ! is_string( $clause['key'] ?? null ) || ! in_array( $clause['compare_key'] ?? '=', array( '=', '' ), true ) ) {
return null;
}
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], $post_type );
if ( ! $field ) {
return null;
}
// Either cross-cutting wp_core or one of the post type's own groups.
$group = $field['group'] ?? '';
return ( 'wp_core' === $group || in_array( $group, TMDO_Post_Fields::groups_for_post_type( $post_type ), true ) ) ? $field : null;
}
/**
* Whether a nested meta query group references a registered key.
*
* @param array<int|string, mixed> $group Nested group.
* @param string $post_type Post type slug.
* @return bool
*/
private static function has_registered_key( array $group, string $post_type ): bool {
foreach ( $group as $clause ) {
if ( ! is_array( $clause ) ) {
continue;
}
if ( isset( $clause['key'] ) ? null !== self::field_for( $clause, $post_type ) : self::has_registered_key( $clause, $post_type ) ) {
return true;
}
}
return false;
}
/**
* The routed form of a clause, or null when the flat column would not
* compare the way WordPress compares the meta_value string.
*
* Native casts meta_value by the clause type (CHAR when absent); flat
* columns are typed. Routed: EXISTS / NOT EXISTS; string columns with
* CHAR; numeric columns with a numeric type (no LIKE); integer columns
* with CHAR =, !=, IN, NOT IN on plain integers ("10" = "10" either way).
*
* @param array<string, mixed> $clause Meta query clause.
* @param array<string, mixed> $field Registered field.
* @return array{group:string, column:string, value:mixed, compare:string, type:string}|null
*/
private static function route_clause( array $clause, array $field ): ?array {
$compare = strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) );
$type = strtoupper( trim( (string) ( $clause['type'] ?? '' ) ) );
$type = '' === $type ? 'CHAR' : $type;
$value = $clause['value'] ?? '';
$kind = $field['type'] ?? 'text';
$multi = in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true );
if ( ! in_array( $compare, self::COMPARES, true ) || ( ! $multi && is_array( $value ) ) ) {
return null;
}
$numeric_type = self::is_numeric_type( $type );
if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
$ok = in_array( $kind, array( 'text', 'textarea', 'enum', 'integer', 'decimal', 'boolean' ), true );
} elseif ( in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) ) {
$ok = 'CHAR' === $type;
} elseif ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) && $numeric_type ) {
$ok = ! in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true );
} else {
$ok = in_array( $kind, array( 'integer', 'boolean' ), true ) && 'CHAR' === $type
&& in_array( $compare, array( '=', '!=', '<>', 'IN', 'NOT IN' ), true )
&& array() === array_filter( (array) $value, static fn( $v ) => ! is_scalar( $v ) || (string) (int) $v !== (string) $v );
}
if ( ! $ok ) {
return null;
}
return array(
'group' => (string) $field['group'],
'column' => TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ),
'value' => $value,
'compare' => $compare,
'type' => $type,
);
}
/**
* What posts_request makes of a routed request.
*
* @param string $request SELECT statement.
* @param \WP_Query $query Current WP_Query.
* @return string
*/
private static function routed_request( string $request, \WP_Query $query ): string {
return self::flat_first( self::without_found_rows( $request ), $query );
}
/**
* Drive a filtered search ordered by a flat column from its flat table.
*
* MariaDB starts from wp_posts (the type / status index), looks every
* candidate up in the flat table and sorts in a temporary table. From the
* flat table, with the posts.ID tie-break read as the equal flat post_id,
* it sorts one table (dev36, 100k listings: meta_value_num 263 → 147 ms,
* HivePress sort + filter 300 → 36 ms). Only when the routed conditions
* already require a flat row (LEFT JOIN = INNER JOIN), all on the sort
* group, without a selective post filter; `tmdo_router_flat_first` can
* turn it off.
*
* @param string $request SELECT statement.
* @param \WP_Query $query Current WP_Query.
* @return string
*/
private static function flat_first( string $request, \WP_Query $query ): string {
$routed = $query->get( self::QUERY_VAR );
$sort = $query->get( self::SORT_VAR );
if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
return $request;
}
$post_type = (string) array_key_first( $routed );
$groups = array_unique( array_column( $routed[ $post_type ], 'group' ) );
$compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) );
if ( empty( $sort ) || 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) {
return $request;
}
foreach ( array( 'p', 'page_id', 'name', 'pagename', 'attachment_id', 'post__in', 'post_parent', 'post_parent__in', 'author', 'author__in', 'author_name' ) as $var ) {
if ( ! empty( $query->get( $var ) ) ) {
return $request;
}
}
if ( ! apply_filters( 'tmdo_router_flat_first', true, $query ) ) {
return $request;
}
global $wpdb;
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $groups[0] );
$alias = self::table_alias( $post_type, $groups[0] );
$on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
$join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}";
if ( 1 !== substr_count( $request, $join ) ) {
return $request;
}
$flat_first = preg_replace(
'/\bFROM\s+' . preg_quote( $wpdb->posts, '/' ) . '\b/',
"FROM `{$table}` AS `{$alias}` INNER JOIN {$wpdb->posts} ON {$on}",
str_replace( $join, '', $request ),
1,
$replaced
);
if ( 1 !== $replaced ) {
return $request;
}
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id.
$flat_first = preg_replace_callback(
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
static fn( $m ) => preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)/', "`{$alias}`.`post_id`", $m[0] ),
(string) $flat_first
);
return (string) preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', (string) $flat_first, 1 );
}
/**
* A request without its SQL_CALC_FOUND_ROWS.
*
* @param string $request SELECT statement.
* @return string
*/
private static function without_found_rows( string $request ): string {
return (string) preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
}
/**
* Whether a meta query type casts to a number (NUMERIC, SIGNED, DECIMAL(p,s) …).
*
* @param string $type Upper-cased clause type.
* @return bool
*/
private static function is_numeric_type( string $type ): bool {
return 1 === preg_match( '/^(NUMERIC|SIGNED|UNSIGNED|DECIMAL)(\(\d+(,\s?\d+)?\))?$/', $type );
}
/**
* Whether ordering by the flat column matches native ordering by
* CAST(meta_value AS type): numbers by a numeric type, strings by CHAR.
*
* @param array{type:string} $route Routed clause.
* @param array<string, mixed> $field Registered field.
* @return bool
*/
private static function sortable( array $route, array $field ): bool {
$kind = $field['type'] ?? 'text';
if ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) ) {
return self::is_numeric_type( $route['type'] );
}
return in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) && 'CHAR' === $route['type'];
}
/**
* The ordering a routed meta orderby resolves to.
*
* @param string $post_type Post type slug.
* @param array{group:string, column:string} $route Routed clause.
* @param string $direction ASC or DESC.
* @return array{post_type:string, group:string, column:string, order:string}
*/
private static function sort_for( string $post_type, array $route, string $direction ): array {
return array(
'post_type' => $post_type,
'group' => $route['group'],
'column' => $route['column'],
'order' => $direction,
);
}
/**
* The clause name of a "sort by a key, keep posts without it" group —
* relation OR of exactly NOT EXISTS and EXISTS on one key and type, one
* of them named (HivePress search sorting) — or '' for any other group.
*
* @param array<int|string, mixed> $group Nested meta query group.
* @return string
*/
private static function sort_shape( array $group ): string {
if ( 'OR' !== strtoupper( (string) ( $group['relation'] ?? '' ) ) ) {
return '';
}
unset( $group['relation'] );
if ( 2 !== count( $group ) ) {
return '';
}
$name = '';
$compares = array();
foreach ( $group as $k => $clause ) {
if ( ! is_array( $clause ) || ! is_string( $clause['key'] ?? null ) ) {
return '';
}
$compares[] = strtoupper( (string) ( $clause['compare'] ?? '=' ) );
$name = is_string( $k ) ? $k : $name;
}
list( $a, $b ) = array_values( $group );
sort( $compares );
$same = $a['key'] === $b['key'] && strtoupper( (string) ( $a['type'] ?? '' ) ) === strtoupper( (string) ( $b['type'] ?? '' ) );
return array( 'EXISTS', 'NOT EXISTS' ) === $compares && $same ? $name : '';
}
/**
* Generate a deterministic JOIN alias for a (post_type, group) pair.
*
@@ -260,9 +750,8 @@ class TMDO_Post_Query_Router {
* @return string Sanitized operator (defaults to '=').
*/
private static function sanitize_compare( string $compare ): string {
$allowed = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' );
$compare = strtoupper( trim( $compare ) );
return in_array( $compare, $allowed, true ) ? $compare : '=';
return in_array( $compare, self::COMPARES, true ) ? $compare : '=';
}
/**
@@ -299,6 +788,10 @@ class TMDO_Post_Query_Router {
return ' AND ' . $col_sql . ' ' . $op;
}
if ( in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true ) ) {
$value = '%' . $wpdb->esc_like( (string) $value ) . '%';
}
return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value );
}
}
+6 -3
View File
@@ -104,7 +104,10 @@ class TMDO_Zone_Router {
*
* Callers only need post_id and post_type; zone enumeration and per-zone
* error isolation are handled here. Hot and Cold are skipped when the
* post_type has no registered fields in that zone (table may not exist).
* post_type has no registered fields in that zone, or when the zone table
* was never created (fields are registered long before a module runs).
* Module state is not checked: a table left by a rolled-back module still
* gets its rows for deleted posts removed.
*
* @param int $post_id Post ID being deleted.
* @param string $post_type Post type slug.
@@ -113,7 +116,7 @@ class TMDO_Zone_Router {
public static function delete_post( int $post_id, string $post_type ): void {
$registry = TMDO_Schema_Registry::instance();
if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) {
if ( ! empty( $registry->get_hot_columns( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Hot::table( $post_type ) ) ) {
try {
TMDO_Zone_Hot::delete( $post_id, $post_type );
} catch ( \Throwable $e ) {
@@ -121,7 +124,7 @@ class TMDO_Zone_Router {
}
}
if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) {
if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Cold::table( $post_type ) ) ) {
try {
TMDO_Zone_Cold::delete( $post_id, $post_type );
} catch ( \Throwable $e ) {
+16 -1
View File
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 8.1
Stable tag: 1.0.0
Stable tag: 1.1.1
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -107,6 +107,21 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
== Changelog ==
= 1.1.1 =
* Security: the public GET /wpdo/v1/listings endpoints returned every registered field (show_in_rest defaulted to true), including private values such as integration tokens and WooCommerce purchase notes. Fields are now private unless registered with show_in_rest => true, and protected (underscore) or credential-like keys are never exposed
* Security: the listings endpoint no longer returns whole rows when no field is public, and only filters / sorts on public columns
= 1.1.0 =
* Search-index mode for HivePress / WooCommerce sites (docs/adr-004): native meta stays the source of truth, flat tables are its search index
* Promotion Guard: aeav_only and cleanup are refused while HivePress or WooCommerce is active (filter `tmdo_promotion_guard_allow`)
* shadow_read reads native meta and samples a flat comparison — fixes lost native writes
* dual_write / shadow_read: flat values follow native after every write (added_/updated_/deleted_ meta actions, delete_all, key change by meta_id)
* Arrays in text fields are stored serialized, never "Array"; `wp tmdo repair-array-literals`
* `wp tmdo bridge-restore-native`: overwrite in aeav_only, only-missing otherwise; no invented default rows
* Post query router: routes only when the result equals native (OR, several post types, untyped numeric compares stay native), LIKE wildcards, one join per group, no GROUP BY, HivePress `_sort` / named / meta_value_num ordering, WordPress split query kept, flat-sorted searches driven from the flat table (filter `tmdo_router_flat_first`)
* Deleting an entity no longer leaves an all-NULL flat row behind
* Zone tables that were never created are never queried
= 1.0.0 =
* Supersedes `wp-data-optimizer` v3.4.6 — that plugin is retired; this one is the single unified engine
* Version numbering restarts at 1.0.0; entries below 3.4.6 are the predecessor's history, kept for archaeology
+14
View File
@@ -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";
}
/** 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 {
$ids = array();
for ( $i = 0; $i < $count; $i++ ) {
@@ -105,6 +111,10 @@ $scenarios = array(
[ $lo, $hi ] = $price();
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(
'native' => function () use ( $price ) {
@@ -127,6 +137,10 @@ $scenarios = array(
[ $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()' );
},
'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(
'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()' ),
+6 -1
View File
@@ -230,8 +230,10 @@ if ( ! function_exists( 'esc_sql' ) ) {
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' ) ) {
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' ) ) {
function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; }
@@ -611,6 +613,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// Engine (v2.0.0).
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -644,6 +647,8 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
// Options Manager.
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Repair of text / textarea flat columns holding the literal "Array" (stored
* by the pre-1.1.0 type caster when the meta value was an array).
*
* Fixture (user entity, group al_prefs):
* user 1 prefs 'Array' native prefs serialized array → repairable
* user 2 loc 'Array' native loc 'Array' too → unrepairable, native polluted
* user 3 name 'Array' no native row → unrepairable
* user 4 prefs serialized array, loc 'Taipei' → untouched
*/
class ArrayLiteralRepairTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_al_prefs';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-cache-orchestrator.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/migration/class-tmdo-array-literal-repair.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, prefs TEXT DEFAULT NULL, loc VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 1, 'prefs' => 'Array' ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 2, 'loc' => 'Array' ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 3, 'name' => 'Array' ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 4, 'prefs' => $prefs, 'loc' => 'Taipei' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'prefs', 'meta_value' => $prefs ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 2, 'meta_key' => 'loc', 'meta_value' => 'Array' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'prefs', 'meta_value' => $prefs ) );
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group(
'user',
'al_prefs',
array(
array( 'key' => 'prefs', 'type' => 'textarea' ),
array( 'key' => 'loc', 'type' => 'text' ),
array( 'key' => 'name', 'type' => 'text' ),
)
);
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->usermeta, self::TABLE ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function flat( int $user_id, string $col ): ?string {
global $wpdb;
return $wpdb->get_var( $wpdb->prepare( "SELECT `{$col}` FROM `" . self::TABLE . '` WHERE user_id = %d', $user_id ) );
}
public function test_dry_run_reports_without_writing(): void {
$report = TMDO_Array_Literal_Repair::run( 'user', false );
$this->assertSame( 3, $report['found'] );
$this->assertSame( 1, $report['repairable'] );
$this->assertSame( 0, $report['repaired'] );
$this->assertSame( 'Array', $this->flat( 1, 'prefs' ) );
}
public function test_apply_repairs_from_native_and_lists_the_rest(): void {
$report = TMDO_Array_Literal_Repair::run( 'user', true );
$this->assertSame( 1, $report['repaired'] );
$this->assertSame( serialize( array( 'core' => array( 'welcomeGuide' => false ) ) ), $this->flat( 1, 'prefs' ) );
$this->assertSame( 'Array', $this->flat( 2, 'loc' ), 'native is "Array" too — nothing to repair from' );
$this->assertSame( 'Array', $this->flat( 3, 'name' ), 'no native row' );
$this->assertSame( 'Taipei', $this->flat( 4, 'loc' ) );
$unrepairable = array_map(
static function ( $r ) {
return $r['id'] . ':' . $r['key'] . ':' . $r['reason'];
},
$report['unrepairable']
);
sort( $unrepairable );
$this->assertSame( array( '2:loc:native_is_array_literal', '3:name:no_native_row' ), $unrepairable );
$this->assertSame( array( array( 'group' => 'al_prefs', 'key' => 'loc', 'id' => 2 ) ), $report['native_polluted'] );
}
public function test_second_run_finds_only_the_unrepairable_rows(): void {
TMDO_Array_Literal_Repair::run( 'user', true );
$report = TMDO_Array_Literal_Repair::run( 'user', true );
$this->assertSame( 2, $report['found'] );
$this->assertSame( 0, $report['repaired'] );
}
}
@@ -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 ) );
}
}
+101 -2
View File
@@ -202,14 +202,89 @@ class HookBusIntegrationTest extends TestCase {
$this->assertTrue( $result, 'aeav_only must return true to short-circuit EAV write' );
}
public function test_update_writes_flat_and_returns_null_in_dual_write_mode(): void {
/**
* dual_write: the pre-filter only lets WP write native; flat is synced
* from native afterwards (updated_/added_ actions → sync_from_native).
*/
public function test_update_leaves_flat_to_the_native_sync_in_dual_write_mode(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
$result = WPDO_Hook_Bus::intercept_update( null, 3, 'first_name', 'Carol', '' );
$this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) );
$this->assertNull( $result, 'dual_write must return null so WP continues to write EAV' );
$this->assertNull( $this->read_flat_first_name( 3 ), 'no flat write before native is written' );
$this->with_usermeta(
function () use ( $wpdb ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 3, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ) );
WPDO_Hook_Bus::sync_from_native( 'user', 3, 'first_name', 'Carol', 'update' );
$this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) );
}
);
}
/**
* delete_all passes object ID 0: the delete_ action records the touched
* users from the meta IDs, the deleted_ action re-syncs each to NULL.
*/
public function test_delete_all_resyncs_every_touched_entity_in_dual_write_mode(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 21, 'first_name' => 'A' ) );
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 22, 'first_name' => 'B' ) );
$this->with_usermeta(
function () use ( $wpdb ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 21, 'meta_key' => 'first_name', 'meta_value' => 'A' ) );
$mid_a = (int) $wpdb->insert_id;
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 22, 'meta_key' => 'first_name', 'meta_value' => 'B' ) );
$mid_b = (int) $wpdb->insert_id;
$GLOBALS['_wp_current_filter'] = 'delete_user_meta';
WPDO_Hook_Bus::before_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' );
$wpdb->query( "DELETE FROM `{$wpdb->usermeta}` WHERE meta_key = 'first_name'" );
$GLOBALS['_wp_current_filter'] = 'deleted_user_meta';
WPDO_Hook_Bus::on_meta_deleted( array( $mid_a, $mid_b ), 0, 'first_name', '' );
$this->assertNull( $this->read_flat_first_name( 21 ) );
$this->assertNull( $this->read_flat_first_name( 22 ) );
}
);
}
/**
* Deleting an entity drops its flat row first (adapter delete hook), then
* WordPress deletes each meta row; syncing those deletions must not bring
* the row back as all NULL.
*/
public function test_sync_of_a_deleted_key_does_not_recreate_a_missing_row(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
$this->with_usermeta(
function () use ( $wpdb ) {
WPDO_Hook_Bus::sync_from_native( 'user', 31, 'first_name', 'Gone', 'delete' );
$this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::FLAT_TABLE . '` WHERE user_id = 31' ) );
}
);
}
/**
* Run $fn with a native usermeta table, dropped afterwards.
*/
private function with_usermeta( callable $fn ): void {
global $wpdb;
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id) ) ENGINE=InnoDB" );
try {
$fn();
} finally {
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" );
}
}
public function test_update_passthrough_for_unregistered_key(): void {
@@ -268,6 +343,30 @@ class HookBusIntegrationTest extends TestCase {
$this->assertNull( $result, 'dual_write does not read from flat — must return $check' );
}
/**
* shadow_read serves native meta: update_metadata() reads the old value
* through this filter, and serving the just-written flat value made WP
* skip the native write (dev36, 2026-09-26). Flat is only compared.
*/
public function test_get_serves_native_in_shadow_read_mode_even_with_a_flat_value(): void {
global $wpdb;
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 12, 'first_name' => 'Flat' ) );
self::set_user_mode( WPDO_Mode_Manager::MODE_SHADOW_READ );
foreach ( array( 0.0, 1.0 ) as $rate ) {
$GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] = array(
static function () use ( $rate ) {
return $rate;
},
);
$result = WPDO_Hook_Bus::intercept_get( null, 12, 'first_name', true, 'user' );
$this->assertNull( $result, "shadow_read must return \$check (sample rate {$rate})" );
}
unset( $GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] );
}
public function test_get_returns_empty_string_when_column_is_null(): void {
global $wpdb;
$wpdb->query(
@@ -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' );
}
}
+73
View File
@@ -139,4 +139,77 @@ class NativeRestorerTest extends TestCase {
$this->assertSame( array( '9' ), $this->native( 1, 'level' ) );
}
private function set_user_mode( string $mode ): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => $mode );
TMDO_Mode_Manager::reset_cache();
}
/**
* Native is the source of truth outside aeav_only (e.g. WooCommerce writes
* stock with direct SQL): the default policy there only fills gaps.
*/
public function test_native_authoritative_modes_only_fill_missing_rows(): void {
$this->set_user_mode( 'dual_write' );
$report = TMDO_Native_Restorer::run( 'user', false );
$this->assertSame( array( '1' ), $this->native( 1, 'level' ), 'differing native value kept' );
$this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ), 'missing native row filled' );
$this->assertSame( array( '5' ), $this->native( 2, 'level' ) );
$this->assertSame( array( 'stale' ), $this->native( 2, 'tags' ), 'NULL flat value does not delete native here' );
$this->assertSame( 'only_missing', $report['policy'] );
$this->assertSame( 2, $report['groups']['rn_profile']['missing'] );
$this->assertSame( 2, $report['groups']['rn_profile']['differs'] );
}
public function test_overwrite_policy_replaces_native_in_any_mode(): void {
$this->set_user_mode( 'dual_write' );
TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'overwrite' );
$this->assertSame( array( '3' ), $this->native( 1, 'level' ) );
$this->assertSame( array(), $this->native( 2, 'tags' ) );
}
public function test_only_missing_policy_keeps_native_in_aeav_only(): void {
TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'only_missing' );
$this->assertSame( array( '1' ), $this->native( 1, 'level' ) );
$this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ) );
}
public function test_dry_run_classifies_without_writing(): void {
$report = TMDO_Native_Restorer::run( 'user', true );
$this->assertSame( 'overwrite', $report['policy'], 'aeav_only: flat is the source of truth' );
$this->assertSame( 2, $report['groups']['rn_profile']['missing'] );
$this->assertSame( 2, $report['groups']['rn_profile']['differs'] );
$this->assertSame( 0, $report['groups']['rn_profile']['same'] );
$this->assertSame( array( '1' ), $this->native( 1, 'level' ) );
}
/**
* A flat column holding its field default for an entity that never had
* the key (e.g. activity.session_count = 0) is not written back.
*/
public function test_defaults_are_not_invented_as_native_rows(): void {
global $wpdb;
$table = 'wp_itest_wpdo_user_rn_counts';
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, visits BIGINT(20) DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB" );
$wpdb->query( "INSERT INTO `{$table}` (user_id, visits, updated_at) VALUES (1, 0, '2020-01-01'), (2, 7, '2020-01-01')" );
WPDO_Entity_Registry::register_group( 'user', 'rn_counts', array( array( 'key' => 'visits', 'type' => 'integer', 'default' => 0 ) ) );
WPDO_Schema_Manager::flush_table_exists_cache();
try {
$report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rn_counts' );
$this->assertSame( array(), $this->native( 1, 'visits' ), 'default 0 not invented' );
$this->assertSame( array( '7' ), $this->native( 2, 'visits' ) );
$this->assertSame( 1, $report['groups']['rn_counts']['default_skipped'] );
} finally {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
}
@@ -0,0 +1,128 @@
<?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} */
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', 'type' => 'NUMERIC' ) ),
),
$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 ),
);
}
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'] );
}
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'] );
}
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' ) ) );
}
public function test_not_exists_keeps_the_legacy_shape(): void {
// The INNER JOIN would drop posts without a flat row, which NOT EXISTS must match.
$this->assert_legacy_shape( $this->sql( array( 'meta_query' => array( array( 'key' => 'rc_price', 'compare' => 'NOT EXISTS' ) ) ) ) );
}
}
@@ -0,0 +1,261 @@
<?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 $clause = array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=', 'type' => 'NUMERIC' ) ): array {
global $wpdb;
$query = new WP_Query();
$query->set( 'post_type', 'fr_item' );
$query->set( 'post_status', 'publish' );
$query->set( 'meta_query', array( $clause ) );
$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' ) );
}
// ── flat-first: a filtered search ordered by a flat column ─────────────
/**
* A routed search ordered by the flat price (named clause), with the
* posts.ID tie-break a plugin such as HivePress appends.
*
* @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string}
*/
private function sorted( string $compare = '>=' ): array {
global $wpdb;
$query = new WP_Query();
$query->set( 'post_type', 'fr_item' );
$query->set( 'post_status', 'publish' );
$query->set( 'meta_query', array( 'price' => array( 'key' => 'fr_price', 'value' => 15, 'compare' => $compare, 'type' => 'NUMERIC' ) ) );
$query->set( 'orderby', 'price' );
$query->set( 'order', 'DESC' );
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
$request = "SELECT SQL_CALC_FOUND_ROWS {$wpdb->posts}.ID FROM {$wpdb->posts} " . $router->posts_join( '', $query )
. " WHERE 1=1 AND {$wpdb->posts}.post_type = 'fr_item' AND (({$wpdb->posts}.post_status = 'publish'))" . $router->posts_where( '', $query )
. ' ORDER BY ' . $router->posts_orderby( '', $query ) . ", {$wpdb->posts}.ID DESC LIMIT 0, 10";
return array( $router, $query, $request );
}
public function test_sorted_filtered_request_drives_from_the_flat_table_with_the_same_rows(): void {
global $wpdb;
list( $router, $query, $request ) = $this->sorted();
$sql = $router->posts_request( $request, $query );
$this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql );
$this->assertStringContainsString( 'FROM `' . self::TABLE . '` AS `wpdoflat_fr_item_fr_core` INNER JOIN ' . $wpdb->posts . ' ON', $sql );
$this->assertStringNotContainsString( 'LEFT JOIN `' . self::TABLE . '`', $sql );
$this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id' );
$this->assertSame( array( '3', '2' ), $wpdb->get_col( $sql ) );
$this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $request ) ), $wpdb->get_col( $sql ) );
}
public function test_date_ordered_filtered_request_keeps_wordpress_join_order(): void {
list( $router, $query, $request ) = $this->routed();
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
}
public function test_flat_first_needs_a_condition_that_rejects_posts_without_a_flat_row(): void {
list( $router, $query, $request ) = $this->sorted( 'NOT EXISTS' );
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
}
public function test_flat_first_is_skipped_for_selective_post_filters(): void {
list( $router, $query, $request ) = $this->sorted();
$query->set( 'post__in', array( 2 ) );
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
}
public function test_flat_first_can_be_turned_off(): void {
list( $router, $query, $request ) = $this->sorted();
$off = static fn() => false;
add_filter( 'tmdo_router_flat_first', $off );
try {
$this->assertStringStartsNotWith( 'SELECT STRAIGHT_JOIN', $router->posts_request( $request, $query ) );
} finally {
remove_filter( 'tmdo_router_flat_first', $off );
}
}
public function test_flat_first_keeps_the_split_query(): void {
global $wpdb;
list( $router, $query, $request ) = $this->sorted();
$old_request = str_replace( "{$wpdb->posts}.ID FROM", "{$wpdb->posts}.* FROM", $request );
$query->request = $router->posts_request( $old_request, $query );
$query->set( 'posts_per_page', 10 );
$this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $query->request );
$this->assertTrue( $router->split_the_query( false, $query, $old_request, array( 'fields' => "{$wpdb->posts}.*", 'limits' => 'LIMIT 0, 10' ) ) );
}
// ── split query: IDs first, then cached post objects ──────────────────
/**
* WordPress's full-row request for a routed query, before and after posts_request.
*
* @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string, 3:array<string, string>}
*/
private function full_rows(): array {
global $wpdb;
list( $router, $query, $request ) = $this->routed();
$old_request = str_replace( "{$wpdb->posts}.ID FROM", "{$wpdb->posts}.* FROM", $request );
$query->request = $router->posts_request( $old_request, $query );
$query->set( 'posts_per_page', 1 );
return array( $router, $query, $old_request, array( 'fields' => "{$wpdb->posts}.*", 'limits' => 'LIMIT 0, 1' ) );
}
public function test_routed_full_row_request_is_still_split(): void {
list( $router, $query, $old_request, $clauses ) = $this->full_rows();
$this->assertTrue( $router->split_the_query( false, $query, $old_request, $clauses ) );
}
public function test_request_changed_by_others_is_not_split(): void {
list( $router, $query, $old_request, $clauses ) = $this->full_rows();
$query->request .= ' /* another plugin */';
$this->assertFalse( $router->split_the_query( false, $query, $old_request, $clauses ) );
}
public function test_unrouted_query_keeps_the_core_decision(): void {
$router = new TMDO_Post_Query_Router();
$this->assertFalse( $router->split_the_query( false, new WP_Query(), 'SELECT 1', array( 'fields' => 'wp_itest_posts.*', 'limits' => 'LIMIT 0, 1' ) ) );
$this->assertTrue( $router->split_the_query( true, new WP_Query(), 'SELECT 1', array() ) );
}
public function test_split_id_request_goes_through_posts_request_too(): void {
unset( $GLOBALS['_wp_filter_callbacks']['posts_request_ids'], $GLOBALS['_wp_filter_callbacks']['split_the_query'] );
$router = new TMDO_Post_Query_Router();
$router->register_hooks();
$this->assertContains( array( $router, 'posts_request' ), $GLOBALS['_wp_filter_callbacks']['posts_request_ids'] ?? array() );
$this->assertContains( array( $router, 'split_the_query' ), $GLOBALS['_wp_filter_callbacks']['split_the_query'] ?? array() );
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Meta orderby on the flat table: the "sort by an attribute, keep posts
* without it" shape (relation OR of NOT EXISTS + EXISTS on one key, named
* clause in orderby — HivePress search sorting), orderby on a named routed
* clause, and meta_key + meta_value(_num).
*
* Only when the flat column orders the way native CASTs meta_value;
* otherwise the whole query stays native.
*/
class PostQueryRouterOrderTest extends TestCase {
private const ALIAS = '`wpdoflat_product_wc_product`';
public static function setUpBeforeClass(): void {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
}
protected function setUp(): void {
$cache = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
}
public static function tearDownAfterClass(): void {
$cache = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, null );
WPDO_Entity_Registry::init();
}
/**
* @param array<string, mixed> $vars Query vars (post_type defaults to product).
* @return array{query:WP_Query, join:string, where:string, orderby:string}
*/
private function route( array $vars ): array {
$query = new WP_Query();
foreach ( $vars + array( 'post_type' => 'product' ) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
return array(
'query' => $query,
'join' => $router->posts_join( '', $query ),
'where' => $router->posts_where( '', $query ),
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
);
}
/**
* @param string $type Clause type.
* @return array<int|string, mixed>
*/
private function sort_shape( string $type ): array {
return array(
array(
'relation' => 'OR',
'price__order' => array( 'key' => '_price', 'type' => $type, 'compare' => 'NOT EXISTS' ),
array( 'key' => '_price', 'type' => $type, 'compare' => 'EXISTS' ),
),
);
}
public function test_sort_shape_left_joins_and_orders_by_the_flat_column_without_filtering(): void {
$sql = $this->route( array( 'meta_query' => $this->sort_shape( 'DECIMAL' ), 'orderby' => 'price__order', 'order' => 'ASC' ) );
$this->assertSame( array(), $sql['query']->get( 'meta_query' ), 'sort group removed from the native meta_query' );
$this->assertStringContainsString( 'LEFT JOIN `wp_itest_wpdo_post_wc_product` AS ' . self::ALIAS, $sql['join'] );
$this->assertStringNotContainsString( '_price', $sql['where'], 'every post matches: no condition' );
$this->assertStringStartsWith( self::ALIAS . '.`_price` ASC', $sql['orderby'] );
}
public function test_sort_shape_with_char_type_on_a_numeric_column_stays_native(): void {
$vars = array( 'meta_query' => $this->sort_shape( 'CHAR' ), 'orderby' => 'price__order', 'order' => 'ASC' );
$sql = $this->route( $vars );
$this->assertSame( $vars['meta_query'], $sql['query']->get( 'meta_query' ) );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
}
public function test_orderby_on_a_named_routed_clause_orders_by_its_column(): void {
$sql = $this->route(
array(
'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ),
'orderby' => array( 'price' => 'DESC' ),
)
);
$this->assertStringContainsString( self::ALIAS . '.`_price` IS NOT NULL', $sql['where'] );
$this->assertStringStartsWith( self::ALIAS . '.`_price` DESC', $sql['orderby'] );
}
public function test_orderby_naming_a_routed_clause_among_other_keys_stays_native(): void {
$vars = array(
'meta_query' => array( 'price' => array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ) ),
'orderby' => array( 'price' => 'DESC', 'title' => 'ASC' ),
);
$sql = $this->route( $vars );
$this->assertSame( $vars['meta_query'], $sql['query']->get( 'meta_query' ) );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
}
public function test_meta_value_num_on_a_numeric_key_requires_the_key_and_orders_by_its_column(): void {
$sql = $this->route( array( 'meta_key' => '_price', 'orderby' => 'meta_value_num', 'order' => 'DESC' ) );
$this->assertSame( '', $sql['query']->get( 'meta_key' ), 'primary meta clause taken over' );
$this->assertStringContainsString( self::ALIAS . '.`_price` IS NOT NULL', $sql['where'], 'native INNER JOIN drops posts without the key' );
$this->assertStringStartsWith( self::ALIAS . '.`_price` DESC', $sql['orderby'] );
}
public function test_meta_value_on_a_string_key_is_routed(): void {
$sql = $this->route( array( 'meta_key' => '_sku', 'orderby' => 'meta_value', 'order' => 'ASC' ) );
$this->assertStringStartsWith( self::ALIAS . '.`_sku` ASC', $sql['orderby'] );
}
public function test_meta_value_on_a_numeric_key_stays_native(): void {
$sql = $this->route( array( 'meta_key' => '_price', 'orderby' => 'meta_value', 'order' => 'ASC' ) );
$this->assertSame( '_price', $sql['query']->get( 'meta_key' ), 'native string order' );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
}
public function test_meta_key_with_a_meta_value_filter_stays_native(): void {
$sql = $this->route( array( 'meta_key' => '_price', 'meta_value' => '5', 'orderby' => 'meta_value_num' ) );
$this->assertSame( '_price', $sql['query']->get( 'meta_key' ) );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
}
}
+176 -7
View File
@@ -78,7 +78,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
),
) );
$original = $query->get( 'meta_query' );
@@ -100,7 +100,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
),
) );
$original = $query->get( 'meta_query' );
@@ -123,7 +123,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
),
) );
@@ -149,7 +149,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
),
) );
@@ -177,7 +177,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ),
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
),
) );
$original = $query->get( 'meta_query' );
@@ -202,7 +202,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50' ),
array( 'key' => '_price', 'value' => '50', 'type' => 'NUMERIC' ),
),
'wpdo_post_clauses' => array(),
) );
@@ -214,6 +214,34 @@ class PostQueryRouterTest extends TestCase {
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
}
public function test_posts_join_joins_every_routed_group(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ),
),
) );
$router->pre_get_posts( $query );
$join = $router->posts_join( '', $query );
$where = $router->posts_where( '', $query );
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
$this->assertStringContainsString( 'wpdo_post_wp_core', $join );
$this->assertStringContainsString( '`wpdoflat_product_wp_core`.`_thumbnail_id` IS NOT NULL', $where );
}
public function test_no_group_by_is_added_for_one_row_per_post_joins(): void {
unset( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] );
( new WPDO_Post_Query_Router() )->register_hooks();
$this->assertEmpty( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] ?? array() );
}
public function test_posts_where_appends_condition_for_routed_clause(): void {
$this->set_post_mode( 'aeav_only' );
@@ -221,7 +249,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '99', 'compare' => '=' ),
array( 'key' => '_price', 'value' => '99', 'compare' => '=', 'type' => 'NUMERIC' ),
),
) );
@@ -242,4 +270,145 @@ class PostQueryRouterTest extends TestCase {
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
}
// ── all-or-nothing: native unless every registered clause is routable ────
/**
* Run pre_get_posts in aeav_only and assert the query was left to WordPress.
*
* @param array<string, mixed> $vars Query vars.
*/
private function assert_not_routed( array $vars, string $why ): void {
$this->set_post_mode( 'aeav_only' );
$query = $this->make_query( $vars );
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertSame( $vars['meta_query'], $query->get( 'meta_query' ), $why . ': meta_query untouched.' );
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $why . ': nothing routed.' );
}
public function test_or_relation_is_not_routed(): void {
$this->assert_not_routed(
array(
'post_type' => 'product',
'meta_query' => array(
'relation' => 'OR',
array( 'key' => '_stock', 'value' => '1' ),
array( 'key' => '_sku', 'value' => 'x' ),
),
),
'OR'
);
}
public function test_several_post_types_are_not_routed(): void {
$this->assert_not_routed(
array(
'post_type' => array( 'product', 'post' ),
'meta_query' => array( array( 'key' => '_price', 'value' => '5', 'compare' => '>=', 'type' => 'NUMERIC' ) ),
),
'several post types'
);
}
public function test_untyped_range_on_a_numeric_column_is_not_routed(): void {
$this->assert_not_routed(
array(
'post_type' => 'product',
'meta_query' => array( array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ) ),
),
'CHAR > on DECIMAL'
);
}
public function test_untyped_equality_on_a_decimal_column_is_not_routed(): void {
$this->assert_not_routed(
array(
'post_type' => 'product',
'meta_query' => array( array( 'key' => '_price', 'value' => '10' ) ),
),
'CHAR = on DECIMAL (native "10.00" != "10")'
);
}
public function test_numeric_type_on_a_text_column_is_not_routed(): void {
$this->assert_not_routed(
array(
'post_type' => 'product',
'meta_query' => array( array( 'key' => '_sku', 'value' => '5', 'compare' => '>', 'type' => 'NUMERIC' ) ),
),
'NUMERIC on VARCHAR'
);
}
public function test_unknown_compare_is_not_routed(): void {
$this->assert_not_routed(
array(
'post_type' => 'product',
'meta_query' => array( array( 'key' => '_sku', 'value' => '^a', 'compare' => 'REGEXP' ) ),
),
'REGEXP'
);
}
public function test_one_unroutable_registered_clause_keeps_the_whole_query_native(): void {
$this->assert_not_routed(
array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ),
array( 'key' => '_price', 'value' => '9000', 'compare' => '>' ),
),
),
'one unroutable clause'
);
}
public function test_nested_group_with_a_registered_key_is_not_routed(): void {
$this->assert_not_routed(
array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_stock', 'value' => '3', 'compare' => '>=', 'type' => 'NUMERIC' ),
array(
'relation' => 'OR',
array( 'key' => '_price', 'compare' => 'NOT EXISTS' ),
array( 'key' => '_price', 'compare' => 'EXISTS' ),
),
),
),
'nested group'
);
}
public function test_untyped_integer_equality_on_an_integer_column_is_routed(): void {
$this->set_post_mode( 'aeav_only' );
$query = $this->make_query(
array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_stock', 'value' => '3' ),
array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
),
)
);
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
$this->assertCount( 1, $query->get( 'meta_query' ), 'unregistered AND clause stays native' );
}
public function test_like_matches_an_escaped_substring(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query(
array(
'post_type' => 'product',
'meta_query' => array( array( 'key' => '_sku', 'value' => 'B1_1%', 'compare' => 'LIKE' ) ),
)
);
$router->pre_get_posts( $query );
$this->assertStringContainsString( "LIKE '%B1\\\\_1\\\\%%'", $router->posts_where( '', $query ) );
}
}
+174
View File
@@ -0,0 +1,174 @@
<?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 );
// aeav_only: the pre-filter itself writes flat (dual_write syncs from native after the write).
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' );
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
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' );
}
}
+41 -1
View File
@@ -33,6 +33,7 @@ class RestApiIntegrationTest extends TestCase {
'zone' => 'hot',
'column' => 'rp_price',
'type' => 'decimal',
'show_in_rest' => true,
] );
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
@@ -40,11 +41,21 @@ class RestApiIntegrationTest extends TestCase {
'zone' => 'hot',
'column' => 'rp_featured',
'type' => 'tinyint',
'show_in_rest' => true,
] );
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
'meta_key' => 'rp_description',
'zone' => 'cold',
'show_in_rest' => true,
] );
// Registered but not public (1.1.1: fields are private by default).
$registry->register( 'integration_rest', [
'post_type' => 'restapi',
'meta_key' => 'rp_cost',
'zone' => 'hot',
'column' => 'rp_cost',
'type' => 'decimal',
] );
// Create hot table.
@@ -53,6 +64,7 @@ class RestApiIntegrationTest extends TestCase {
post_id BIGINT UNSIGNED NOT NULL,
rp_price DECIMAL(10,2) DEFAULT NULL,
rp_featured TINYINT(1) DEFAULT NULL,
rp_cost DECIMAL(10,2) DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
@@ -91,7 +103,8 @@ class RestApiIntegrationTest extends TestCase {
$featured = $i % 2;
$status = $statuses[ $i ] ?? 'publish';
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES ($i, 'restapi', '$status')" );
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" );
$cost = 1000 - $price;
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured, rp_cost) VALUES ($i, $price, $featured, $cost)" );
}
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES (9, 'post', 'publish')" );
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES (9, 900, 1)" );
@@ -194,6 +207,33 @@ class RestApiIntegrationTest extends TestCase {
$this->assertTrue( true );
}
public function test_listings_never_return_a_private_column(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
foreach ( self::$api->get_listings( $req )->get_data() as $item ) {
$this->assertArrayNotHasKey( 'rp_cost', $item );
}
}
public function test_private_column_cannot_be_used_as_a_filter(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'rp_cost_min', 800 );
$this->assertSame( '5', self::$api->get_listings( $req )->get_headers()['X-WP-Total'], 'filter on a private column is ignored' );
}
public function test_private_column_cannot_be_used_for_ordering(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
$req->set_param( 'orderby', 'rp_cost' );
$req->set_param( 'order', 'ASC' );
// rp_cost descends as post_id rises; ignoring it falls back to post_id ASC.
$this->assertSame( array( 1, 2, 3, 4, 5 ), array_column( self::$api->get_listings( $req )->get_data(), 'id' ) );
}
public function test_listings_filter_price_min(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' );
+11 -2
View File
@@ -129,7 +129,8 @@ $wpdb = new class( $_mysqli ) {
public function get_var( string $sql ): ?string {
$result = $this->db->query( $sql );
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' ) {
@@ -152,7 +153,8 @@ $wpdb = new class( $_mysqli ) {
$result = $this->db->query( $sql );
if ( ! $result ) { return []; }
$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;
}
@@ -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( '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( 'get_post' ) ) { function get_post( $post_id ) { return $GLOBALS['_wp_posts'][ (int) $post_id ] ?? null; } }
if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
if ( ! function_exists( 'is_post_publicly_viewable' ) ) {
@@ -407,12 +410,15 @@ if ( ! function_exists( 'wp_mkdir_p' ) ) {
}
$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_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 ( ! class_exists( 'WP_Query' ) ) {
class WP_Query {
public string $request = '';
private array $vars = [];
public function get( string $key, $default = '' ) { return $this->vars[ $key ] ?? $default; }
public function set( string $key, $value ): void { $this->vars[ $key ] = $value; }
@@ -571,6 +577,7 @@ require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php';
require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -582,6 +589,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-auto-promoter.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
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-user.php';
@@ -604,6 +612,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php';
+60
View File
@@ -0,0 +1,60 @@
# Live test suite
Tests that must run inside a real WordPress site, because the stubbed
`tests/integration` bootstrap cannot reproduce:
- WP core meta flows (`update_metadata()` reads the old value through
`get_metadata_raw()` before writing — the shadow_read write-loss bug lives there)
- the SQL that `WP_Query` actually builds, and hook priorities
- HivePress / WooCommerce behaviour (`pre_get_posts` sort clauses, empty-key
meta reads, direct-SQL stock updates)
**Never run against a production site.** Suites create and delete fixtures
(`tmdo_live_*` users, `tmdo-live-*` terms). Entity modes are overridden in
process through `pre_option_wpdo_bridge_modes`; nothing is written to
`wpdo_bridge_modes` or `wpdo_hook_bus_enabled`.
The reference site is dev36 (HivePress + Marketplace + WooCommerce), with this
plugin symlinked from the `plugins-dev` worktree while developing.
## Run
```bash
wp --path=/var/www/Studio/wp-local-dev36 eval-file tests/live/run.php <suite> [<suite> ...]
```
Exit code 0 when every test passed, 1 otherwise. Output per test:
| Status | Meaning |
|---|---|
| PASS / FAIL | normal |
| XFAIL | known bug, test marked `xfail` with the PR that fixes it; not counted as a failure |
| XPASS | an `xfail` test passed — counted as a failure so the fixing PR removes the marker |
| SKIP | the site does not register the key / taxonomy the test needs |
## Suites
| Suite | Covers |
|---|---|
| `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) |
| `serialized` | array meta values are stored serialized in flat tables, never "Array" |
| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) |
| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route, meta orderby: HivePress sort shape, named clause, meta_key + meta_value_num); routed SQL has no GROUP BY |
| `rest` | anonymous GET of the public listings endpoints returns no protected or credential-like field (1.1.1) |
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
## Tools (`tools/`)
Benchmark and data-flow helpers used for the 2026-09-26 dev36 integration test.
Output files go to the current directory (`qbench`) or `$TMDO_LIVE_OUT`
(default `/tmp`, `run_batch.sh`), never into the repo.
| Tool | Use |
|---|---|
| `seed.php` | `wp eval-file seed.php <vendors\|listings\|requests\|reviews\|products\|orders\|terms> <n> [tag]` — seed through real HivePress / WC APIs |
| `run_batch.sh` | `run_batch.sh <tag> [divisor]` — parallel seeding batch (`TMDO_LIVE_WP_PATH`, `TMDO_LIVE_OUT`) |
| `clone.sql` | bench only: clone hp_listing rows to reach large scale (`sed "s/__K__/<n>/" clone.sql \| wp db query`) |
| `checker.php` | `wp eval-file checker.php [entity]` — native vs flat consistency per registered key (SQL only) |
| `qbench.php` / `qdiff.php` | run 16 WP_Query shapes, then diff two runs (native vs routed): result sets, order, latency, SQL errors |
| `mut.php` | data-flow mutations through HivePress models, WC CRUD and core APIs; prints native / flat / API values |
| `fe.sh` + `zz-s36-probe.php` | front-end search probe (`TMDO_LIVE_URL`). Copy the probe into `wp-content/mu-plugins/` only for the run and remove it afterwards; it only answers requests carrying `X-S36-Probe: 1` |
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* Minimal assertion + test registry for the live suite.
*
* Why not PHPUnit: the live suite runs inside a real WordPress request
* (`wp eval-file`) so WP core's update_metadata() flow, WP_Query SQL and
* third-party hooks (HivePress, WooCommerce) are exercised for real — the
* stubbed integration bootstrap cannot reproduce those.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* Thrown by the assert helpers.
*/
class TMDO_Live_Failure extends RuntimeException {}
/**
* Thrown to skip a test whose preconditions the site does not meet.
*/
class TMDO_Live_Skip extends RuntimeException {}
/**
* Test registry and runner.
*/
final class TMDO_Live {
/**
* Registered tests.
*
* @var array<int, array{suite:string, name:string, fn:callable, xfail:string}>
*/
private static array $tests = array();
/**
* Suite currently being loaded.
*
* @var string
*/
public static string $suite = '';
/**
* Register a test.
*
* `xfail` marks a known bug: a failure is reported as XFAIL (not counted),
* a pass is reported as XPASS and counted as a failure so the marker gets
* removed by the PR that fixes the bug.
*
* @param string $name Test name.
* @param callable $fn Test body.
* @param array $opts Options: xfail => reason.
*/
public static function test( string $name, callable $fn, array $opts = array() ): void {
self::$tests[] = array(
'suite' => self::$suite,
'name' => $name,
'fn' => $fn,
'xfail' => (string) ( $opts['xfail'] ?? '' ),
);
}
/**
* Run every registered test; return true when nothing failed.
*/
public static function run(): bool {
$counts = array( 'PASS' => 0, 'FAIL' => 0, 'XFAIL' => 0, 'XPASS' => 0, 'SKIP' => 0 );
foreach ( self::$tests as $t ) {
$detail = '';
try {
( $t['fn'] )();
$status = '' !== $t['xfail'] ? 'XPASS' : 'PASS';
} catch ( TMDO_Live_Skip $e ) {
$status = 'SKIP';
$detail = $e->getMessage();
} catch ( Throwable $e ) {
$status = '' !== $t['xfail'] ? 'XFAIL' : 'FAIL';
$detail = ( $e instanceof TMDO_Live_Failure ? '' : get_class( $e ) . ': ' ) . $e->getMessage();
} finally {
TMDO_Live_Fixtures::teardown();
}
++$counts[ $status ];
if ( '' !== $t['xfail'] ) {
$detail = trim( $detail . ' [xfail: ' . $t['xfail'] . ']' );
}
printf( "%-5s %s :: %s%s\n", $status, $t['suite'], $t['name'], '' !== $detail ? "\n " . $detail : '' );
}
printf(
"\n%d passed, %d failed, %d xfail, %d xpass, %d skipped\n",
$counts['PASS'],
$counts['FAIL'],
$counts['XFAIL'],
$counts['XPASS'],
$counts['SKIP']
);
return 0 === $counts['FAIL'] && 0 === $counts['XPASS'];
}
}
/**
* Fail unless $actual is identical to $expected.
*
* @param mixed $expected Expected value.
* @param mixed $actual Actual value.
* @param string $message Context.
*/
function tmdo_live_assert_same( $expected, $actual, string $message ): void {
if ( $expected !== $actual ) {
throw new TMDO_Live_Failure(
sprintf( '%s — expected %s, got %s', $message, var_export( $expected, true ), var_export( $actual, true ) ) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
);
}
}
/**
* Fail unless $condition is true.
*
* @param bool $condition Condition.
* @param string $message Context.
*/
function tmdo_live_assert_true( bool $condition, string $message ): void {
if ( ! $condition ) {
throw new TMDO_Live_Failure( $message );
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
/**
* Live-suite fixtures: in-process mode overrides, throwaway entities, and
* direct reads of native meta / flat tables (bypassing every meta filter).
*
* Nothing here persists a mode change: modes are overridden through the
* `pre_option_wpdo_bridge_modes` filter for the current process only.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* Fixture helpers, reset after every test by TMDO_Live::run().
*/
final class TMDO_Live_Fixtures {
/**
* Entities created by the current test, deleted on teardown.
*
* @var array<int, array{0:string, 1:int, 2?:string}>
*/
private static array $created = array();
/**
* Active pre_option callback, if any.
*
* @var callable|null
*/
private static $mode_filter = null;
/**
* Override entity modes for this process and make sure the Hook Bus is hooked.
*
* @param array<string, string> $modes e.g. array( 'user' => 'shadow_read' ).
*/
public static function use_modes( array $modes ): void {
self::clear_mode_filter();
$stored = get_option( 'wpdo_bridge_modes', array() );
$merged = array_merge( is_array( $stored ) ? $stored : array(), $modes );
self::$mode_filter = static function () use ( $merged ) {
return $merged;
};
add_filter( 'pre_option_wpdo_bridge_modes', self::$mode_filter );
self::reset_caches();
// The Hook Bus only boots when wpdo_hook_bus_enabled is on; the suite
// must not depend on (or change) that site option.
if ( false === has_filter( 'update_user_metadata', array( 'TMDO_Hook_Bus', 'intercept_update' ) ) ) {
TMDO_Hook_Bus::init();
}
}
/**
* Create a throwaway user.
*/
public static function user(): int {
$login = 'tmdo_live_' . wp_generate_password( 6, false, false );
$id = wp_insert_user(
array(
'user_login' => $login,
'user_email' => $login . '@example.test',
'user_pass' => wp_generate_password(),
)
);
if ( is_wp_error( $id ) ) {
throw new TMDO_Live_Failure( 'fixture user: ' . $id->get_error_message() );
}
self::$created[] = array( 'user', (int) $id );
return (int) $id;
}
/**
* Create a throwaway term.
*
* @param string $taxonomy Taxonomy.
*/
public static function term( string $taxonomy ): int {
if ( ! taxonomy_exists( $taxonomy ) ) {
throw new TMDO_Live_Skip( "taxonomy {$taxonomy} not registered on this site" );
}
$t = wp_insert_term( 'tmdo-live-' . wp_generate_password( 6, false, false ), $taxonomy );
if ( is_wp_error( $t ) ) {
throw new TMDO_Live_Failure( 'fixture term: ' . $t->get_error_message() );
}
self::$created[] = array( 'term', (int) $t['term_id'], $taxonomy );
return (int) $t['term_id'];
}
/**
* Create a throwaway post.
*
* @param string $post_type Post type.
*/
public static function post( string $post_type ): int {
if ( ! post_type_exists( $post_type ) ) {
throw new TMDO_Live_Skip( "post type {$post_type} not registered on this site" );
}
$id = wp_insert_post(
array(
'post_type' => $post_type,
'post_title' => 'tmdo-live-' . wp_generate_password( 6, false, false ),
'post_status' => 'draft',
),
true
);
if ( is_wp_error( $id ) ) {
throw new TMDO_Live_Failure( 'fixture post: ' . $id->get_error_message() );
}
self::$created[] = array( 'post', (int) $id );
return (int) $id;
}
/**
* Create a throwaway comment (on a throwaway post of $post_type).
*
* @param string $post_type Post type of the parent post.
* @param string $comment_type Comment type.
*/
public static function comment( string $post_type, string $comment_type = 'comment' ): int {
$post_id = self::post( $post_type );
$id = wp_insert_comment(
array(
'comment_post_ID' => $post_id,
'comment_content' => 'tmdo-live',
'comment_type' => $comment_type,
'comment_approved' => 1,
)
);
if ( ! $id ) {
throw new TMDO_Live_Failure( 'fixture comment could not be created' );
}
self::$created[] = array( 'comment', (int) $id );
return (int) $id;
}
/**
* Native meta values for a key, read with SQL (no filters, no cache).
*
* @param string $type Entity type (post|user|term|comment).
* @param int $id Entity ID.
* @param string $key Meta key.
* @return string[]
*/
public static function native( string $type, int $id, string $key ): array {
global $wpdb;
$table = _get_meta_table( $type );
$id_col = $type . '_id';
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column names are WP internals.
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$table} WHERE {$id_col} = %d AND meta_key = %s ORDER BY {$pk}", $id, $key ) );
}
/**
* Raw flat-table value for a key; 'NO-ROW' when the entity has no row.
*
* @param string $type Entity type.
* @param int $id Entity ID.
* @param string $key Meta key.
* @param string $post_type For posts: the post type whose group owns the key.
* @return string|null
*/
public static function flat( string $type, int $id, string $key, string $post_type = '' ) {
global $wpdb;
$field = self::field( $type, $key, $post_type );
$table = TMDO_Schema_Manager::get_table_name( $type, $field['group'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column come from the registry.
$row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$col}` AS v FROM `{$table}` WHERE {$type}_id = %d", $id ), ARRAY_A );
return null === $row ? 'NO-ROW' : $row['v'];
}
/**
* Registry field for a key, or skip the test when the site does not register it.
*
* A key can live in several post groups (hp_rating in listing and vendor
* groups); pass the post type to pick the group scoped to it.
*
* @param string $type Entity type.
* @param string $key Meta key.
* @param string $post_type For posts: the post type whose group owns the key.
*/
public static function field( string $type, string $key, string $post_type = '' ): array {
$field = TMDO_Entity_Registry::get_field( $type, $key );
if ( '' !== $post_type ) {
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
if ( in_array( $post_type, TMDO_Entity_Registry::get_group_post_types( $type, $group ), true )
&& in_array( $key, TMDO_Entity_Registry::get_group_keys( $type, $group ), true ) ) {
$field = array( 'group' => $group ) + (array) $field;
break;
}
}
}
if ( ! $field ) {
throw new TMDO_Live_Skip( "{$type} key {$key} is not registered on this site" );
}
if ( ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( $type, $field['group'] ) ) ) {
throw new TMDO_Live_Skip( "flat table for {$type}/{$field['group']} does not exist" );
}
return $field;
}
/**
* Delete fixtures and drop the mode override.
*/
public static function teardown(): void {
require_once ABSPATH . 'wp-admin/includes/user.php';
foreach ( array_reverse( self::$created ) as $c ) {
if ( 'user' === $c[0] ) {
wp_delete_user( $c[1] );
} elseif ( 'term' === $c[0] ) {
wp_delete_term( $c[1], $c[2] );
} elseif ( 'post' === $c[0] ) {
wp_delete_post( $c[1], true );
} elseif ( 'comment' === $c[0] ) {
wp_delete_comment( $c[1], true );
}
}
self::$created = array();
self::clear_mode_filter();
self::reset_caches();
}
/**
* Remove the pre_option override, if any.
*/
private static function clear_mode_filter(): void {
if ( null !== self::$mode_filter ) {
remove_filter( 'pre_option_wpdo_bridge_modes', self::$mode_filter );
self::$mode_filter = null;
}
}
/**
* Reset request-level caches that memoise modes or routing decisions.
*/
private static function reset_caches(): void {
TMDO_Mode_Manager::reset_cache();
TMDO_Routing_Predicate::flush_cache();
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
/**
* Live test runner — runs suites inside a real WordPress site (HivePress /
* WooCommerce active) so WP core meta flows and query SQL are exercised for real.
*
* Usage:
* wp --path=<site> eval-file tests/live/run.php <suite> [<suite> ...]
*
* Exit code 0 when every test passed (XFAIL allowed), 1 otherwise.
* Never run against a production site: suites create and delete fixtures.
*
* No strict_types here: wp eval-file evals this file, and a declare must be
* the first statement of a real file. The required files declare it.
*
* @package WP_Data_Optimizer
*/
require __DIR__ . '/lib/assert.php';
require __DIR__ . '/lib/fixtures.php';
$tmdo_live_suites = isset( $args ) ? (array) $args : array();
if ( ! class_exists( 'TMDO_Hook_Bus' ) ) {
fwrite( STDERR, "2meet-data-optimizer is not active on this site.\n" );
exit( 1 );
}
if ( empty( $tmdo_live_suites ) ) {
$tmdo_live_available = array_map(
static function ( $f ) {
return basename( $f, '.php' );
},
glob( __DIR__ . '/suites/*.php' )
);
fwrite( STDERR, 'Usage: wp eval-file tests/live/run.php <suite> ... Suites: ' . implode( ', ', $tmdo_live_available ) . "\n" );
exit( 1 );
}
foreach ( $tmdo_live_suites as $tmdo_live_suite ) {
$tmdo_live_file = __DIR__ . '/suites/' . basename( (string) $tmdo_live_suite ) . '.php';
if ( ! is_file( $tmdo_live_file ) ) {
fwrite( STDERR, "Unknown suite: {$tmdo_live_suite}\n" );
exit( 1 );
}
TMDO_Live::$suite = (string) $tmdo_live_suite;
require $tmdo_live_file;
}
exit( TMDO_Live::run() ? 0 : 1 );
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* Suite: the public listings endpoints never return private fields to anonymous users.
*
* Bug (dev36, 2026-09-26, fixed in 1.1.1): GET /wpdo/v1/listings/{id} is public and
* returned every registered hot / cold field, because show_in_rest defaulted to true —
* e.g. the infocards IG token fields on hp_vendor and WooCommerce's _purchase_note.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* GET a wpdo REST route as an anonymous visitor.
*
* @param string $route Route, e.g. /wpdo/v1/listings/12.
* @param array<string, mixed> $params Query parameters.
* @return array<int|string, mixed> Response data.
*/
function tmdo_live_rest_anonymous( string $route, array $params = array() ): array {
$user = get_current_user_id();
$request = new WP_REST_Request( 'GET', $route );
$request->set_query_params( $params );
wp_set_current_user( 0 );
try {
$response = rest_do_request( $request );
} finally {
wp_set_current_user( $user );
}
tmdo_live_assert_same( 200, $response->get_status(), "GET {$route}" );
return (array) $response->get_data();
}
/**
* Keys that must never reach an anonymous visitor.
*
* @return string[]
*/
function tmdo_live_rest_private_keys(): array {
return array( 'tmeetic_ig_access_token_enc', 'tmeetic_ig_token_expiry', 'tmeetic_ig_user_id', '_purchase_note' );
}
TMDO_Live::test(
'GET /wpdo/v1/listings/{id} returns no private field to an anonymous visitor',
static function () {
$checked = 0;
foreach ( array( 'hp_vendor', 'product' ) as $post_type ) {
$ids = get_posts( array( 'post_type' => $post_type, 'post_status' => 'publish', 'numberposts' => 1, 'fields' => 'ids' ) );
if ( ! $ids ) {
continue;
}
$data = tmdo_live_rest_anonymous( '/wpdo/v1/listings/' . (int) $ids[0] );
tmdo_live_assert_same( array(), array_values( array_intersect( tmdo_live_rest_private_keys(), array_keys( $data ) ) ), "{$post_type} #{$ids[0]} private keys" );
tmdo_live_assert_same( array(), array_values( preg_grep( '/^_|token|secret|passw|api_?key/i', array_keys( $data ) ) ), "{$post_type} #{$ids[0]} protected / credential-like keys" );
++$checked;
}
if ( 0 === $checked ) {
throw new TMDO_Live_Skip( 'no published hp_vendor or product on this site' );
}
}
);
TMDO_Live::test(
'GET /wpdo/v1/listings returns no private field to an anonymous visitor',
static function () {
foreach ( array( 'hp_vendor', 'product', 'hp_listing' ) as $post_type ) {
if ( ! post_type_exists( $post_type ) ) {
continue;
}
foreach ( tmdo_live_rest_anonymous( '/wpdo/v1/listings', array( 'post_type' => $post_type, 'per_page' => 5 ) ) as $item ) {
tmdo_live_assert_same( array(), array_values( preg_grep( '/^_|token|secret|passw|api_?key/i', array_keys( (array) $item ) ) ), "{$post_type} list item keys" );
}
}
}
);
+138
View File
@@ -0,0 +1,138 @@
<?php
/**
* Suite: in dual_write / shadow_read the flat value always follows native meta.
*
* Before PR-057 the Hook Bus wrote flat in the pre-filter, before WP decided
* whether native changed at all: a non-matching prev_value, add($unique) on an
* existing key or a key change via update_metadata_by_mid left flat and native
* apart, and nothing healed a flat value made stale by a direct SQL write.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* Entity cases: [entity type, key, fixture factory, post type for group lookup].
*
* @return array<string, array{0:string, 1:string, 2:callable, 3:string}>
*/
function tmdo_live_resync_cases(): array {
return array(
'user first_name' => array( 'user', 'first_name', array( 'TMDO_Live_Fixtures', 'user' ), '' ),
'hp_listing price' => array( 'post', 'hp_price', static fn() => TMDO_Live_Fixtures::post( 'hp_listing' ), 'hp_listing' ),
'hp term icon' => array( 'term', 'hp_icon', static fn() => TMDO_Live_Fixtures::term( 'hp_listing_category' ), '' ),
'hp review rating' => array( 'comment', 'hp_rating', static fn() => TMDO_Live_Fixtures::comment( 'hp_listing', 'hp_review' ), '' ),
);
}
/**
* Assert flat equals the first native value (numeric-aware; missing native = NULL flat).
*/
function tmdo_live_assert_in_step( string $type, int $id, string $key, string $post_type, string $context ): void {
$native = TMDO_Live_Fixtures::native( $type, $id, $key );
$flat = TMDO_Live_Fixtures::flat( $type, $id, $key, $post_type );
$expect = $native ? $native[0] : null;
if ( 'NO-ROW' === $flat ) {
$flat = null;
}
$same = ( null === $expect || null === $flat )
? $expect === $flat
: ( is_numeric( $expect ) && is_numeric( $flat ) ? (float) $expect === (float) $flat : $expect === $flat );
tmdo_live_assert_true( $same, sprintf( '%s — native %s, flat %s', $context, var_export( $expect, true ), var_export( $flat, true ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
}
foreach ( array( 'dual_write', 'shadow_read' ) as $tmdo_live_mode ) {
foreach ( tmdo_live_resync_cases() as $tmdo_live_label => $tmdo_live_case ) {
list( $type, $key, $make, $pt ) = $tmdo_live_case;
TMDO_Live::test(
"{$tmdo_live_mode} {$tmdo_live_label}: update, same value, delete",
static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) {
TMDO_Live_Fixtures::field( $type, $key, $pt );
TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) );
$id = $make();
update_metadata( $type, $id, $key, '11' );
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'new key' );
update_metadata( $type, $id, $key, '12' );
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'update' );
update_metadata( $type, $id, $key, '12' );
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'same value' );
delete_metadata( $type, $id, $key, '12' );
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'delete with value' );
}
);
TMDO_Live::test(
"{$tmdo_live_mode} {$tmdo_live_label}: writes WP refuses leave flat alone",
static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) {
TMDO_Live_Fixtures::field( $type, $key, $pt );
TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) );
$id = $make();
update_metadata( $type, $id, $key, '21' );
update_metadata( $type, $id, $key, '22', 'no-such-previous-value' );
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'non-matching prev_value' );
add_metadata( $type, $id, $key, '23', true );
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'add unique on existing key' );
update_metadata( $type, $id, $key, '24', '21' );
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'matching prev_value' );
}
);
TMDO_Live::test(
"{$tmdo_live_mode} {$tmdo_live_label}: stale flat heals on the next write, key change by meta_id",
static function () use ( $tmdo_live_mode, $type, $key, $make, $pt ) {
global $wpdb;
TMDO_Live_Fixtures::field( $type, $key, $pt );
TMDO_Live_Fixtures::use_modes( array( $type => $tmdo_live_mode ) );
$id = $make();
update_metadata( $type, $id, $key, '31' );
$table = _get_meta_table( $type );
$id_col = $type . '_id';
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
// A direct SQL write (as WooCommerce does for stock) leaves flat stale.
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP meta table names.
$wpdb->query( $wpdb->prepare( "UPDATE {$table} SET meta_value = '32' WHERE {$id_col} = %d AND meta_key = %s", $id, $key ) );
wp_cache_delete( $id, $type . '_meta' );
update_metadata( $type, $id, $key, '33' );
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'next write after a direct SQL change' );
// Renaming the key by meta_id removes it from native: flat must follow.
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP meta table names.
$mid = (int) $wpdb->get_var( $wpdb->prepare( "SELECT {$pk} FROM {$table} WHERE {$id_col} = %d AND meta_key = %s", $id, $key ) );
update_metadata_by_mid( $type, $mid, '34', 'tmdo_live_renamed' );
tmdo_live_assert_in_step( $type, $id, $key, $pt, 'key renamed by meta_id' );
}
);
}
}
TMDO_Live::test(
'deleting an entity leaves no flat row behind (dual_write and shadow_read)',
static function () {
require_once ABSPATH . 'wp-admin/includes/user.php';
$deleters = array(
'user' => static fn( int $id ) => wp_delete_user( $id ),
'post' => static fn( int $id ) => wp_delete_post( $id, true ),
'term' => static fn( int $id ) => wp_delete_term( $id, 'hp_listing_category' ),
'comment' => static fn( int $id ) => wp_delete_comment( $id, true ),
);
foreach ( array( 'dual_write', 'shadow_read' ) as $mode ) {
foreach ( tmdo_live_resync_cases() as $label => $case ) {
list( $type, $key, $make, $pt ) = $case;
TMDO_Live_Fixtures::field( $type, $key, $pt );
TMDO_Live_Fixtures::use_modes( array( $type => $mode ) );
$id = $make();
update_metadata( $type, $id, $key, '41' );
$deleters[ $type ]( $id );
tmdo_live_assert_same( 'NO-ROW', TMDO_Live_Fixtures::flat( $type, $id, $key, $pt ), "{$mode} {$label}: flat row after delete" );
}
}
}
);
+425
View File
@@ -0,0 +1,425 @@
<?php
/**
* Suite: a routed WP_Query returns exactly what the native meta_query returns.
*
* Bugs (dev36 qbench, 2026-09-26): the post router pulled registered clauses
* out of any meta_query and ANDed them, so OR relations (Q04, Q16), queries on
* several post types (Q08), untyped comparisons on numeric columns (Q14,
* native compares strings) and orderby on a named clause (Q15) returned other
* posts than WordPress would; LIKE compared without wildcards (Q06). A key
* of a second group (wp_core _thumbnail_id, Q05) was never joined, and every
* routed query carried a GROUP BY although flat rows are one per post.
*
* Every case runs the same query twice on fixture posts — router hooks off,
* then on with post mode shadow_read — and compares the ids in order.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* Run a WP_Query restricted to $ids, optionally through the post router.
*
* @param array<string, mixed> $args Query args (meta_query, post_type, orderby …).
* @param int[] $ids Fixture posts to search within.
* @param bool $routed Hook a TMDO_Post_Query_Router for this query.
* @return array{ids:int[], routed:bool, sql:string}
*/
function tmdo_live_router_query( array $args, array $ids, bool $routed ): array {
global $wpdb;
$router = new TMDO_Post_Query_Router();
if ( $routed ) {
$router->register_hooks();
}
try {
$wpdb->last_error = '';
$q = new WP_Query(
$args + array(
'post__in' => $ids,
'post_status' => 'any',
'posts_per_page' => -1,
'orderby' => 'ID',
'order' => 'ASC',
'fields' => 'ids',
'no_found_rows' => true,
'cache_results' => false,
'suppress_filters' => false,
)
);
} finally {
tmdo_live_router_unhook( $router );
}
tmdo_live_assert_same( '', $wpdb->last_error, 'SQL error' );
return array(
'ids' => array_map( 'intval', $q->posts ),
'routed' => ! empty( $q->get( 'wpdo_post_clauses' ) ) || ! empty( $q->get( 'wpdo_post_sort' ) ),
'sql' => (string) $q->request,
);
}
/**
* Remove every hook TMDO_Post_Query_Router::register_hooks() added.
*
* @param TMDO_Post_Query_Router $router Router instance.
*/
function tmdo_live_router_unhook( TMDO_Post_Query_Router $router ): void {
foreach ( array( 'pre_get_posts', 'posts_join', 'posts_where', 'posts_orderby', 'posts_request', 'split_the_query', 'found_posts_query' ) as $method ) {
foreach ( 'posts_request' === $method ? array( 'posts_request', 'posts_request_ids' ) : array( $method ) as $hook ) {
$priority = has_filter( $hook, array( $router, $method ) );
if ( false !== $priority ) {
remove_filter( $hook, array( $router, $method ), $priority );
}
}
}
}
/**
* Create posts of $post_type with the given meta, in post mode shadow_read
* (so the Hook Bus keeps the flat rows in step).
*
* @param string $post_type Post type.
* @param array<string, array<string, mixed>> $posts Label => meta.
* @return array<string, int> Label => post id.
*/
function tmdo_live_router_posts( string $post_type, array $posts ): array {
$ids = array();
foreach ( $posts as $label => $meta ) {
$ids[ $label ] = TMDO_Live_Fixtures::post( $post_type );
foreach ( $meta as $key => $value ) {
update_post_meta( $ids[ $label ], $key, $value );
}
}
return $ids;
}
/**
* Listing fixtures shared by the hp_listing cases.
*
* @return array<string, int>
*/
function tmdo_live_router_listings(): array {
foreach ( array( 'hp_price', 'hp_verified', 'hp_featured', 'hp_hourly_rate' ) as $key ) {
TMDO_Live_Fixtures::field( 'post', $key, 'hp_listing' );
}
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
return tmdo_live_router_posts(
'hp_listing',
array(
'L1' => array( 'hp_price' => '10000', 'hp_featured' => '1', 'hp_verified' => '0', 'hp_hourly_rate' => '40' ),
'L2' => array( 'hp_price' => '9500', 'hp_verified' => '1', 'hp_hourly_rate' => '30' ),
'L3' => array( 'hp_price' => '500', 'hp_featured' => '0', 'hp_hourly_rate' => '45' ),
'L4' => array( 'hp_price' => '20000', 'tmdo_live_unregistered' => '0' ),
'L5' => array(),
)
);
}
/**
* Assert the routed query equals native; optionally that it was routed at all.
*
* @param array<string, mixed> $args Query args.
* @param int[] $ids Fixture ids.
* @param bool|null $expect_routed true / false to assert, null to ignore.
* @param bool $ordered Compare order too (false when the order has ties).
*/
function tmdo_live_router_assert_native( array $args, array $ids, ?bool $expect_routed = null, bool $ordered = true ): void {
$native = tmdo_live_router_query( $args, $ids, false );
$routed = tmdo_live_router_query( $args, $ids, true );
if ( ! $ordered ) {
sort( $native['ids'] );
sort( $routed['ids'] );
}
tmdo_live_assert_same( $native['ids'], $routed['ids'], 'routed ids vs native' );
if ( null !== $expect_routed ) {
tmdo_live_assert_same( $expect_routed, $routed['routed'], 'query routed to the flat table' );
}
if ( $routed['routed'] ) {
tmdo_live_assert_true( false === stripos( $routed['sql'], 'GROUP BY' ), 'routed query adds no GROUP BY: ' . $routed['sql'] );
}
}
TMDO_Live::test(
'Q04 OR across registered keys returns the native posts',
static function () {
$l = tmdo_live_router_listings();
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'meta_query' => array(
'relation' => 'OR',
array( 'key' => 'hp_featured', 'value' => '1' ),
array( 'key' => 'hp_verified', 'value' => '1' ),
),
),
array_values( $l )
);
}
);
TMDO_Live::test(
'Q16 OR mixing a registered and an unregistered key returns the native posts',
static function () {
$l = tmdo_live_router_listings();
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'meta_query' => array(
'relation' => 'OR',
array( 'key' => 'hp_price', 'value' => 1000, 'compare' => '<', 'type' => 'NUMERIC' ),
array( 'key' => 'tmdo_live_unregistered', 'value' => '0' ),
),
),
array_values( $l )
);
}
);
TMDO_Live::test(
'Q14 untyped comparison on a numeric column keeps string semantics',
static function () {
$l = tmdo_live_router_listings();
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'meta_query' => array( array( 'key' => 'hp_price', 'value' => '9000', 'compare' => '>' ) ),
),
array_values( $l )
);
}
);
TMDO_Live::test(
'Q15 orderby on a named clause keeps the native order, routed',
static function () {
$l = tmdo_live_router_listings();
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'meta_query' => array(
'rate' => array( 'key' => 'hp_hourly_rate', 'value' => 50, 'compare' => '<', 'type' => 'NUMERIC' ),
'price' => array( 'key' => 'hp_price', 'compare' => 'EXISTS', 'type' => 'NUMERIC' ),
),
'orderby' => 'price',
'order' => 'ASC',
),
array_values( $l ),
true
);
}
);
TMDO_Live::test(
'Q08 several post types return the native posts',
static function () {
$l = tmdo_live_router_listings();
TMDO_Live_Fixtures::field( 'post', 'hp_budget', 'hp_request' );
$r = tmdo_live_router_posts( 'hp_request', array( 'R1' => array( 'hp_budget' => '90000' ) ) );
update_post_meta( $l['L2'], 'hp_budget', '85000' );
tmdo_live_router_assert_native(
array(
'post_type' => array( 'hp_listing', 'hp_request' ),
'meta_query' => array( array( 'key' => 'hp_budget', 'value' => 80000, 'compare' => '>=', 'type' => 'NUMERIC' ) ),
),
array_merge( array_values( $l ), array_values( $r ) )
);
}
);
TMDO_Live::test(
'typed AND on registered keys is routed and returns the native posts',
static function () {
$l = tmdo_live_router_listings();
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'meta_query' => array(
array( 'key' => 'hp_price', 'value' => array( 9000, 15000 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
array( 'key' => 'hp_verified', 'value' => '1' ),
),
),
array_values( $l ),
true
);
}
);
TMDO_Live::test(
'Q06 LIKE matches substrings and escapes wildcards, routed',
static function () {
TMDO_Live_Fixtures::field( 'post', '_sku', 'product' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
$p = tmdo_live_router_posts(
'product',
array(
'P1' => array( '_sku' => 'tmdo-live-B1-1' ),
'P2' => array( '_sku' => 'tmdo-live-B1-10' ),
'P3' => array( '_sku' => 'tmdo-live-B2-1' ),
)
);
foreach ( array( 'B1-1', 'B1_1' ) as $needle ) {
tmdo_live_router_assert_native(
array(
'post_type' => 'product',
'meta_query' => array( array( 'key' => '_sku', 'value' => $needle, 'compare' => 'LIKE' ) ),
),
array_values( $p ),
true
);
}
}
);
TMDO_Live::test(
'Q05 keys of two groups are both joined, routed',
static function () {
$l = tmdo_live_router_listings();
TMDO_Live_Fixtures::field( 'post', '_thumbnail_id', 'hp_listing' );
update_post_meta( $l['L1'], '_thumbnail_id', '123' );
update_post_meta( $l['L3'], '_thumbnail_id', '124' );
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'meta_query' => array(
array( 'key' => 'hp_hourly_rate', 'value' => 42, 'compare' => '>=', 'type' => 'NUMERIC' ),
array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ),
),
),
array_values( $l ),
true
);
}
);
TMDO_Live::test(
'NOT EXISTS with explicit statuses and date order keeps posts without a flat row',
static function () {
$l = tmdo_live_router_listings();
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'post_status' => array( 'draft' ),
'orderby' => 'date',
'order' => 'DESC',
'meta_query' => array( array( 'key' => 'hp_featured', 'compare' => 'NOT EXISTS' ) ),
),
array_values( $l ),
true,
false
);
}
);
TMDO_Live::test(
'HivePress sort shape (NOT EXISTS OR EXISTS, named clause) keeps the native order, routed',
static function () {
$l = tmdo_live_router_listings();
foreach ( array( 'ASC', 'DESC' ) as $order ) {
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'meta_query' => array(
array(
'relation' => 'OR',
'price__order' => array( 'key' => 'hp_price', 'type' => 'DECIMAL', 'compare' => 'NOT EXISTS' ),
array( 'key' => 'hp_price', 'type' => 'DECIMAL', 'compare' => 'EXISTS' ),
),
),
'orderby' => 'price__order',
'order' => $order,
),
array_values( $l ),
true
);
}
}
);
TMDO_Live::test(
'Q07 meta_key + meta_value_num keeps the native posts and order, routed',
static function () {
$l = tmdo_live_router_listings();
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'orderby' => 'meta_value_num',
'order' => 'DESC',
),
array_values( $l ),
true
);
}
);
TMDO_Live::test(
'a routed full-row search keeps the split query (IDs first) and native found_posts',
static function () {
global $wpdb;
$l = tmdo_live_router_listings();
$args = array(
'post_type' => 'hp_listing',
'post__in' => array_values( $l ),
'post_status' => 'any',
'posts_per_page' => 2,
'orderby' => 'ID',
'order' => 'ASC',
'meta_query' => array( array( 'key' => 'hp_price', 'value' => 1000, 'compare' => '>=', 'type' => 'NUMERIC' ) ),
);
$native = new WP_Query( $args );
$router = new TMDO_Post_Query_Router();
$router->register_hooks();
try {
$routed = new WP_Query( $args );
} finally {
tmdo_live_router_unhook( $router );
}
tmdo_live_assert_same( wp_list_pluck( $native->posts, 'ID' ), wp_list_pluck( $routed->posts, 'ID' ), 'page of posts' );
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
tmdo_live_assert_true( false !== strpos( $routed->request, 'wpdoflat_' ), 'routed: ' . $routed->request );
tmdo_live_assert_true( 1 === preg_match( '/^\s*SELECT\s+' . preg_quote( $wpdb->posts, '/' ) . '\.ID\s/', $routed->request ), 'IDs first: ' . $routed->request );
}
);
TMDO_Live::test(
'a filtered search ordered by a flat column drives from the flat table and matches native',
static function () {
foreach ( array( 'hp_price', 'hp_hourly_rate' ) as $key ) {
TMDO_Live_Fixtures::field( 'post', $key, 'hp_listing' );
}
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
// A price band no seeded listing uses keeps the result to the fixtures.
tmdo_live_router_posts(
'hp_listing',
array(
'A' => array( 'hp_price' => '9876501', 'hp_hourly_rate' => '12' ),
'B' => array( 'hp_price' => '9876502', 'hp_hourly_rate' => '15' ),
'C' => array( 'hp_price' => '9876503' ),
)
);
$args = array(
'post_type' => 'hp_listing',
'post_status' => 'any',
'posts_per_page' => 10,
'fields' => 'ids',
'suppress_filters' => false,
'meta_query' => array( array( 'key' => 'hp_price', 'value' => array( 9876500, 9876510 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
'meta_key' => 'hp_price',
'orderby' => 'meta_value_num',
'order' => 'DESC',
);
$native = new WP_Query( $args );
$router = new TMDO_Post_Query_Router();
$router->register_hooks();
try {
$routed = new WP_Query( $args );
} finally {
tmdo_live_router_unhook( $router );
}
tmdo_live_assert_same( array_map( 'intval', $native->posts ), array_map( 'intval', $routed->posts ), 'ids' );
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request );
}
);
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* Suite: array meta values survive the flat tables (no "Array" literal).
*
* Bug (dev36, 2026-09-26): admin_prefs `community-events-location` (text) and
* `wp_persisted_preferences` (textarea) hold arrays; the type caster stored
* (string) $array = "Array", and bridge-restore-native wrote it back to native.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
TMDO_Live::test(
'dual_write: array written with update_user_meta is stored serialized',
static function () {
TMDO_Live_Fixtures::field( 'user', 'wp_persisted_preferences' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) );
$uid = TMDO_Live_Fixtures::user();
$prefs = array( 'core' => array( 'welcomeGuide' => false ) );
$loc = array( 'ip' => '192.0.2.0' );
update_user_meta( $uid, 'wp_persisted_preferences', $prefs );
update_user_meta( $uid, 'community-events-location', $loc );
tmdo_live_assert_same( serialize( $prefs ), TMDO_Live_Fixtures::flat( 'user', $uid, 'wp_persisted_preferences' ), 'flat wp_persisted_preferences' );
tmdo_live_assert_same( serialize( $loc ), TMDO_Live_Fixtures::flat( 'user', $uid, 'community-events-location' ), 'flat community-events-location' );
}
);
TMDO_Live::test(
'backfill: native array meta is flattened serialized, not "Array"',
static function () {
TMDO_Live_Fixtures::field( 'user', 'community-events-location' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'disabled' ) );
$uid = TMDO_Live_Fixtures::user();
$loc = array( 'ip' => '198.51.100.0' );
update_user_meta( $uid, 'community-events-location', $loc );
tmdo_live_assert_same( array( serialize( $loc ) ), TMDO_Live_Fixtures::native( 'user', $uid, 'community-events-location' ), 'native precondition' );
$field = TMDO_Entity_Registry::get_field( 'user', 'community-events-location' );
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $field['group'] );
TMDO_Entity_Migration_Engine::migrate_group( 'user', $field['group'], array( 'sleep_ms' => 0, 'resume' => false ) );
tmdo_live_assert_same( serialize( $loc ), TMDO_Live_Fixtures::flat( 'user', $uid, 'community-events-location' ), 'flat after backfill' );
}
);
TMDO_Live::test(
'read path: from_db returns the array, not a serialized string',
static function () {
$field = TMDO_Live_Fixtures::field( 'user', 'wp_persisted_preferences' );
$prefs = array( 'core/edit-post' => array( 'panels' => array( 'a', 'b' ) ) );
$stored = TMDO_Type_Caster::to_db( $prefs, $field );
tmdo_live_assert_same( $prefs, TMDO_Type_Caster::from_db( $stored, $field ), 'round trip' );
}
);
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* Suite: writes made in shadow_read must reach native meta.
*
* Bug (dev36, 2026-09-26): in shadow_read, update_metadata() fetches the old
* value through get_metadata_raw(); the Hook Bus has already upserted the new
* value and serves it from the flat table, so WP sees old === new and returns
* false without writing native meta. Demoting afterwards exposes stale data.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
TMDO_Live::test(
'dual_write: user first_name update reaches native and flat (control)',
static function () {
TMDO_Live_Fixtures::field( 'user', 'first_name' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) );
$uid = TMDO_Live_Fixtures::user();
update_user_meta( $uid, 'first_name', 'Before' );
$ret = update_user_meta( $uid, 'first_name', 'After' );
tmdo_live_assert_true( false !== $ret, 'update_user_meta returned false' );
tmdo_live_assert_same( array( 'After' ), TMDO_Live_Fixtures::native( 'user', $uid, 'first_name' ), 'native first_name' );
tmdo_live_assert_same( 'After', TMDO_Live_Fixtures::flat( 'user', $uid, 'first_name' ), 'flat first_name' );
}
);
TMDO_Live::test(
'shadow_read: user first_name update reaches native',
static function () {
TMDO_Live_Fixtures::field( 'user', 'first_name' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) );
$uid = TMDO_Live_Fixtures::user();
update_user_meta( $uid, 'first_name', 'Before' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'shadow_read' ) );
$ret = update_user_meta( $uid, 'first_name', 'After' );
tmdo_live_assert_true( false !== $ret, 'update_user_meta returned false' );
tmdo_live_assert_same( array( 'After' ), TMDO_Live_Fixtures::native( 'user', $uid, 'first_name' ), 'native first_name' );
}
);
TMDO_Live::test(
'shadow_read → dual_write: value written in shadow_read survives demotion',
static function () {
TMDO_Live_Fixtures::field( 'user', 'last_name' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) );
$uid = TMDO_Live_Fixtures::user();
update_user_meta( $uid, 'last_name', 'Before' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'shadow_read' ) );
update_user_meta( $uid, 'last_name', 'After' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) );
wp_cache_delete( $uid, 'user_meta' );
tmdo_live_assert_same( 'After', get_user_meta( $uid, 'last_name', true ), 'last_name after demotion' );
}
);
TMDO_Live::test(
'shadow_read: HivePress term hp_icon update reaches native',
static function () {
TMDO_Live_Fixtures::field( 'term', 'hp_icon' );
TMDO_Live_Fixtures::use_modes( array( 'term' => 'dual_write' ) );
$tid = TMDO_Live_Fixtures::term( 'hp_listing_category' );
update_term_meta( $tid, 'hp_icon', 'star' );
TMDO_Live_Fixtures::use_modes( array( 'term' => 'shadow_read' ) );
$ret = update_term_meta( $tid, 'hp_icon', 'heart' );
tmdo_live_assert_true( false !== $ret, 'update_term_meta returned false' );
tmdo_live_assert_same( array( 'heart' ), TMDO_Live_Fixtures::native( 'term', $tid, 'hp_icon' ), 'native hp_icon' );
}
);
TMDO_Live::test(
'shadow_read: HivePress listing hp_rating_count (new int key) reaches native',
static function () {
TMDO_Live_Fixtures::field( 'post', 'hp_rating_count', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'dual_write' ) );
$lid = TMDO_Live_Fixtures::post( 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
// HivePress writes the recomputed count as an int; the flat read cast it
// to the same int, so the old code skipped the native write.
$ret = update_post_meta( $lid, 'hp_rating_count', 1 );
tmdo_live_assert_true( false !== $ret, 'update_post_meta returned false' );
tmdo_live_assert_same( array( '1' ), TMDO_Live_Fixtures::native( 'post', $lid, 'hp_rating_count' ), 'native hp_rating_count' );
tmdo_live_assert_same( '1', TMDO_Live_Fixtures::flat( 'post', $lid, 'hp_rating_count', 'hp_listing' ), 'flat hp_rating_count' );
}
);
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Suite: zone tables that were never created are never queried.
*
* Bug (dev36, 2026-09-26): the HivePress AddOn registers cold fields, so every
* save_post / loop_start (PR-054) and every post delete (PR-054b) queried
* wp_wpdo_cold_{post_type} although the cold module was idle and the table
* did not exist ("table doesn't exist" DB errors on each write).
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
TMDO_Live::test(
'saving and deleting an hp_listing does not touch missing zone tables',
static function () {
global $wpdb;
$tables = array( TMDO_Zone_Cold::table( 'hp_listing' ), TMDO_Zone_Hot::table( 'hp_listing' ) );
foreach ( $tables as $table ) {
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
throw new TMDO_Live_Skip( "{$table} exists on this site" );
}
}
$touched = array();
$spy = static function ( $sql ) use ( $tables, &$touched ) {
foreach ( $tables as $table ) {
if ( false !== strpos( $sql, $table ) && 0 !== stripos( ltrim( $sql ), 'SHOW TABLES' ) ) {
$touched[] = $sql;
}
}
return $sql;
};
add_filter( 'query', $spy );
try {
$id = TMDO_Live_Fixtures::post( 'hp_listing' );
wp_update_post( array( 'ID' => $id, 'post_title' => 'tmdo-live-updated' ) );
wp_delete_post( $id, true );
} finally {
remove_filter( 'query', $spy );
}
tmdo_live_assert_same( array(), $touched, 'queries on missing zone tables' );
}
);
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Independent native-meta vs flat-table consistency checker (does not use TMDO read paths).
* Usage: wp eval-file checker.php [entity]
* Per registered group/key: native rows, flat non-null, missing-in-flat, value mismatches, extra-in-flat.
*/
global $wpdb;
$only = $args[0] ?? '';
$ids = array( 'post' => 'post_id', 'user' => 'user_id', 'term' => 'term_id', 'comment' => 'comment_id' );
$metas = array( 'post' => $wpdb->postmeta, 'user' => $wpdb->usermeta, 'term' => $wpdb->termmeta, 'comment' => $wpdb->commentmeta );
$tot = array( 'miss' => 0, 'diff' => 0, 'extra' => 0 );
printf( "%-8s %-24s %-28s %8s %8s %6s %6s %6s\n", 'entity', 'group', 'key', 'native', 'flat', 'miss', 'diff', 'extra' );
foreach ( $ids as $e => $idcol ) {
if ( $only && $only !== $e ) { continue; }
$mt = $metas[ $e ];
$mid = ( 'user' === $e ) ? 'umeta_id' : 'meta_id';
foreach ( TMDO_Entity_Registry::get_groups_for_type( $e ) as $g ) {
$table = $wpdb->prefix . 'wpdo_' . $e . '_' . $g;
if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) { continue; }
$scope = ( 'post' === $e ) ? TMDO_Entity_Registry::get_group_post_types( $e, $g ) : array();
$scope_sql = $scope ? " AND m.post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_type IN ('" . implode( "','", array_map( 'esc_sql', $scope ) ) . "'))" : '';
foreach ( TMDO_Entity_Registry::get_group_keys( $e, $g ) as $key ) {
$col = preg_replace( '/[^a-zA-Z0-9_]/', '', $key );
// Latest native value per entity.
$nat = $wpdb->prepare( "SELECT m.$idcol AS eid, m.meta_value AS v FROM $mt m JOIN (SELECT $idcol, MAX($mid) mid FROM $mt WHERE meta_key=%s GROUP BY $idcol) x ON x.mid=m.$mid WHERE 1=1 $scope_sql", $key );
$n_native = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ($nat) n" );
$n_flat = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table WHERE `$col` IS NOT NULL" );
$miss = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ($nat) n LEFT JOIN $table f ON f.$idcol=n.eid WHERE f.$idcol IS NULL OR (f.`$col` IS NULL AND n.v <> '')" );
// Value diff: numeric-aware loose compare.
$diff = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ($nat) n JOIN $table f ON f.$idcol=n.eid WHERE f.`$col` IS NOT NULL AND NOT (
CAST(f.`$col` AS CHAR) = n.v
OR (n.v REGEXP '^-?[0-9]+(\\\\.[0-9]+)?$' AND CAST(f.`$col` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6)))
OR (n.v='' AND CAST(f.`$col` AS CHAR) IN ('','0'))
)" );
$extra = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table f WHERE f.`$col` IS NOT NULL AND NOT EXISTS (SELECT 1 FROM $mt m WHERE m.$idcol=f.$idcol AND m.meta_key='" . esc_sql( $key ) . "')" );
if ( $n_native || $n_flat ) {
printf( "%-8s %-24s %-28s %8d %8d %6d %6d %6d\n", $e, $g, $key, $n_native, $n_flat, $miss, $diff, $extra );
}
$tot['miss'] += $miss;
$tot['diff'] += $diff;
$tot['extra'] += $extra;
}
}
}
printf( "TOTAL miss=%d diff=%d extra=%d\n", $tot['miss'], $tot['diff'], $tot['extra'] );
+12
View File
@@ -0,0 +1,12 @@
-- Clone original hp_listing rows (ID < 20000) into copy k with ID offset k*20000.
-- Bench-only: clones hp_listing rows into offset IDs. Never run on a production site.
-- Usage: sed "s/__K__/<n>/" clone.sql | wp db query
SET @k := __K__; SET @off := @k * 20000;
INSERT INTO wp_posts (ID, post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, post_status, comment_status, ping_status, post_password, post_name, to_ping, pinged, post_modified, post_modified_gmt, post_content_filtered, post_parent, guid, menu_order, post_type, post_mime_type, comment_count)
SELECT ID+@off, post_author, d, d, post_content, CONCAT(post_title,' c',@k), post_excerpt, post_status, comment_status, ping_status, post_password, CONCAT(post_name,'-c',@k), to_ping, pinged, d, d, post_content_filtered, post_parent, CONCAT(guid,'&c=',@k), menu_order, post_type, post_mime_type, comment_count
FROM (SELECT *, DATE_SUB('2026-09-26 12:00:00', INTERVAL FLOOR(RAND()*63072000) SECOND) d FROM wp_posts WHERE post_type='hp_listing' AND ID < 20000) s;
INSERT INTO wp_postmeta (post_id, meta_key, meta_value)
SELECT m.post_id+@off, m.meta_key, CASE m.meta_key WHEN 'hp_price' THEN FLOOR(100+RAND()*49900) WHEN 'hp_hourly_rate' THEN FLOOR(10+RAND()*290) ELSE m.meta_value END
FROM wp_postmeta m JOIN wp_posts p ON p.ID=m.post_id WHERE p.post_type='hp_listing' AND p.ID < 20000 AND m.meta_key NOT LIKE '\_transient%';
INSERT INTO wp_term_relationships (object_id, term_taxonomy_id, term_order)
SELECT r.object_id+@off, r.term_taxonomy_id, r.term_order FROM wp_term_relationships r JOIN wp_posts p ON p.ID=r.object_id WHERE p.post_type='hp_listing' AND p.ID < 20000;
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# usage: fe.sh <label> — front-end HivePress/WC search probe, 5 runs each, median ms
B="${TMDO_LIVE_URL:-https://dev36.2meet.pro}"
URLS=(
"/?post_type=hp_listing&s=&price[]=10000&price[]=20000"
"/?post_type=hp_listing&s=&price[]=10000&price[]=20000&_sort=price__asc"
"/?post_type=hp_listing&s=&hourly_rate[]=10&hourly_rate[]=50&_sort=hourly_rate__desc"
"/?post_type=hp_listing&s=&_sort=rating__desc"
"/?post_type=hp_listing&s=Taipei&price[]=1000&price[]=30000"
"/?post_type=hp_vendor&s=&hourly_rate[]=100&hourly_rate[]=200"
"/?post_type=hp_request&s="
"/shop/?min_price=1000&max_price=5000"
"/shop/?orderby=price-desc"
)
for u in "${URLS[@]}"; do
ms=(); for i in 1 2 3 4 5; do h=$(curl -s -o /dev/null -D - -H "X-S36-Probe: 1" "$B$u"); ms+=($(echo "$h" | grep -i x-s36-ms | awk "{print \$2}" | tr -dc 0-9)); done
med=$(printf "%s\n" "${ms[@]}" | sort -n | sed -n 3p)
echo "$u|$(echo "$h" | grep -i x-s36-found | awk "{print \$2}" | tr -dc 0-9)|$(echo "$h" | grep -i x-s36-ids | cut -d' ' -f2 | tr -d '\r')|$(echo "$h" | grep -i x-s36-queries | awk "{print \$2}" | tr -dc 0-9)|$med"
done
+61
View File
@@ -0,0 +1,61 @@
<?php
// Data-flow mutation test. Usage: wp eval-file mut.php <label>
use HivePress\Models;
add_filter( 'pre_http_request', function ( $r, $a, $u ) { return false !== strpos( $u, 'pixel.wp.com' ) ? array( 'headers' => array(), 'body' => '', 'response' => array( 'code' => 200, 'message' => 'OK' ), 'cookies' => array() ) : $r; }, 1, 3 );
global $wpdb;
$mode = TMDO_Mode_Manager::get( 'post' ) . '/' . TMDO_Mode_Manager::get( 'user' );
function nat( $t, $id, $k ) { global $wpdb; $mt = $t === 'user' ? $wpdb->usermeta : $wpdb->postmeta; $c = $t === 'user' ? 'user_id' : 'post_id'; $v = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM $mt WHERE $c=%d AND meta_key=%s", $id, $k ) ); return $v ? implode( '|', $v ) : '∅'; }
function flat( $t, $g, $id, $k ) { global $wpdb; $c = $t === 'user' ? 'user_id' : 'post_id'; $v = $wpdb->get_row( $wpdb->prepare( "SELECT `$k` v FROM {$wpdb->prefix}wpdo_{$t}_{$g} WHERE $c=%d", $id ) ); return $v ? ( $v->v ?? 'NULL' ) : 'NO-ROW'; }
function row( $name, $vals ) { printf( "%-44s %s\n", $name, implode( ' ', array_map( fn( $k, $v ) => "$k=" . ( is_scalar( $v ) || is_null( $v ) ? var_export( $v, true ) : json_encode( $v ) ), array_keys( $vals ), $vals ) ) ); }
function clear_caches( $id ) { wp_cache_flush(); clean_post_cache( $id ); }
echo "== mode post/user = $mode\n";
// T1: HivePress model update of a listing price.
$lid = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} WHERE post_type='hp_listing' AND post_status='publish' ORDER BY ID DESC LIMIT 1" );
$new = mt_rand( 60000, 69999 );
update_post_meta( $lid, 'hp_price', $new ); // HP stores attributes via meta API
clear_caches( $lid );
$m = Models\Listing::query()->get_by_id( $lid );
row( "T1 listing $lid hp_price:=$new", array( 'native' => nat( 'post', $lid, 'hp_price' ), 'flat' => flat( 'post', 'hp_listing_core', $lid, 'hp_price' ), 'get_meta' => get_post_meta( $lid, 'hp_price', true ), 'all_meta' => get_post_meta( $lid )['hp_price'][0] ?? '∅' ) );
// T2: HivePress model field (verified) via model save.
$m->set_verified( ! $m->is_verified() )->save( array( 'verified' ) );
clear_caches( $lid );
$m2 = Models\Listing::query()->get_by_id( $lid );
row( "T2 listing verified via HP model", array( 'native' => nat( 'post', $lid, 'hp_verified' ), 'flat' => flat( 'post', 'hp_listing_core', $lid, 'hp_verified' ), 'hp_model' => $m2->is_verified(), 'get_meta' => get_post_meta( $lid, 'hp_verified', true ) ) );
// T3: WC product price via CRUD.
$pid = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} WHERE post_type='product' AND post_title LIKE 'Product B1%' ORDER BY ID DESC LIMIT 1" );
$p = wc_get_product( $pid ); $np = (string) mt_rand( 7000, 7999 ); $p->set_regular_price( $np ); $p->set_sale_price( '' ); $p->save();
clear_caches( $pid );
row( "T3 product $pid _regular_price:=$np", array( 'native' => nat( 'post', $pid, '_regular_price' ), 'flat' => flat( 'post', 'wc_product', $pid, '_regular_price' ), 'wc_read' => wc_get_product( $pid )->get_regular_price(), 'lookup' => $wpdb->get_var( "SELECT min_price FROM {$wpdb->prefix}wc_product_meta_lookup WHERE product_id=$pid" ) ) );
// T4: order reduces stock (WC direct SQL path).
$before = wc_get_product( $pid )->get_stock_quantity();
$o = wc_create_order(); $o->add_product( wc_get_product( $pid ), 2 ); $o->calculate_totals( false ); $o->set_status( 'processing' ); $o->save(); wc_reduce_stock_levels( $o->get_id() );
clear_caches( $pid );
row( "T4 order x2 on product $pid (stock $before)", array( 'native' => nat( 'post', $pid, '_stock' ), 'flat' => flat( 'post', 'wc_product', $pid, '_stock' ), 'get_meta' => get_post_meta( $pid, '_stock', true ), 'wc_read' => wc_get_product( $pid )->get_stock_quantity(), 'sales_native' => nat( 'post', $pid, 'total_sales' ), 'sales_flat' => flat( 'post', 'wc_product', $pid, 'total_sales' ) ) );
// T5: delete a meta key.
update_post_meta( $lid, 'hp_featured', 1 ); delete_post_meta( $lid, 'hp_featured' ); clear_caches( $lid );
row( "T5 delete_post_meta hp_featured", array( 'native' => nat( 'post', $lid, 'hp_featured' ), 'flat' => flat( 'post', 'hp_listing_core', $lid, 'hp_featured' ), 'get_meta' => var_export( get_post_meta( $lid, 'hp_featured', true ), true ), 'exists' => metadata_exists( 'post', $lid, 'hp_featured' ) ) );
// T6: user first_name via wp_update_user.
$uid = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->users} WHERE user_login LIKE 's36v_B1p1_%' ORDER BY ID LIMIT 1" );
$fn = 'Fn' . mt_rand( 100, 999 ); wp_update_user( array( 'ID' => $uid, 'first_name' => $fn ) ); wp_cache_flush();
$u = get_userdata( $uid );
row( "T6 user $uid first_name:=$fn", array( 'native' => nat( 'user', $uid, 'first_name' ), 'flat' => flat( 'user', 'core_profile', $uid, 'first_name' ), 'get_meta' => get_user_meta( $uid, 'first_name', true ), 'WP_User' => $u->first_name, 'wc_customer' => ( new WC_Customer( $uid ) )->get_first_name() ) );
// T7: WC customer billing city via CRUD.
$c = new WC_Customer( $uid ); $city = 'City' . mt_rand( 10, 99 ); $c->set_billing_city( $city ); $c->save(); wp_cache_flush();
row( "T7 WC_Customer billing_city:=$city", array( 'native' => nat( 'user', $uid, 'billing_city' ), 'flat' => flat( 'user', 'commerce', $uid, 'billing_city' ), 'get_meta' => get_user_meta( $uid, 'billing_city', true ), 'wc_customer' => ( new WC_Customer( $uid ) )->get_billing_city() ) );
// T8: delete a listing → flat row?
$d = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} WHERE post_type='hp_listing' AND post_status='draft' ORDER BY ID DESC LIMIT 1" );
wp_delete_post( $d, true );
row( "T8 wp_delete_post $d", array( 'posts' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE ID=$d" ), 'native_meta' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id=$d" ), 'flat_row' => flat( 'post', 'hp_listing_core', $d, 'post_id' ) ) );
// T9: HP review → listing rating recompute.
$before = get_post_meta( $lid, 'hp_rating_count', true );
$rv = ( new Models\Review() )->fill( array( 'text' => 'mut', 'rating' => 5, 'approved' => 1, 'author' => $uid, 'author__display_name' => 'x', 'author__email' => 'x@example.test', 'listing' => $lid ) ); $rv->save(); wp_cache_flush();
row( "T9 add review (count was $before)", array( 'native' => nat( 'post', $lid, 'hp_rating_count' ), 'flat' => flat( 'post', 'hp_listing_core', $lid, 'hp_rating_count' ), 'hp_model' => Models\Listing::query()->get_by_id( $lid )->get_rating_count() ) );
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* Query correctness + latency harness. Runs identical WP_Query shapes and dumps
* ids / found_posts / timings to JSON so two runs (native vs routed mode) can be diffed.
* Usage: wp eval-file qbench.php <label> [iterations]
*/
global $wpdb;
$label = $args[0] ?? 'run';
$iters = (int) ( $args[1] ?? 5 );
$out = getcwd() . "/qbench_{$label}.json";
// Deterministic parameters derived from data (same in every run).
$vendor_ids = array_map( 'intval', $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type='hp_vendor' AND post_status='publish' ORDER BY ID LIMIT 3" ) );
$L = array( 'post_type' => 'hp_listing', 'post_status' => 'publish', 'posts_per_page' => 20 );
$cases = array(
'Q01 price BETWEEN (NUMERIC)' => $L + array( 'meta_query' => array( array( 'key' => 'hp_price', 'value' => array( 10000, 20000 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ) ),
'Q02 price >= AND verified=1' => $L + array( 'meta_query' => array( 'relation' => 'AND', array( 'key' => 'hp_price', 'value' => 25000, 'compare' => '>=', 'type' => 'NUMERIC' ), array( 'key' => 'hp_verified', 'value' => '1' ) ) ),
'Q03 rating >= 4.5 (DECIMAL)' => $L + array( 'meta_query' => array( array( 'key' => 'hp_rating', 'value' => '4.5', 'compare' => '>=', 'type' => 'DECIMAL(10,2)' ) ) ),
'Q04 featured=1 OR verified=1' => $L + array( 'meta_query' => array( 'relation' => 'OR', array( 'key' => 'hp_featured', 'value' => '1' ), array( 'key' => 'hp_verified', 'value' => '1' ) ) ),
'Q05 rate>=100 AND _thumbnail EXISTS'=> $L + array( 'meta_query' => array( array( 'key' => 'hp_hourly_rate', 'value' => 100, 'compare' => '>=', 'type' => 'NUMERIC' ), array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ) ) ),
'Q06 product _sku LIKE' => array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => 20, 'meta_query' => array( array( 'key' => '_sku', 'value' => 'S36-B1-1', 'compare' => 'LIKE' ) ) ),
'Q07 orderby meta_value_num price' => $L + array( 'meta_key' => 'hp_price', 'orderby' => 'meta_value_num', 'order' => 'DESC' ),
'Q08 multi post_type + hp_budget' => array( 'post_type' => array( 'hp_listing', 'hp_request' ), 'post_status' => 'publish', 'posts_per_page' => 20, 'meta_query' => array( array( 'key' => 'hp_budget', 'value' => 80000, 'compare' => '>=', 'type' => 'NUMERIC' ) ) ),
'Q09 featured NOT EXISTS' => $L + array( 'meta_query' => array( array( 'key' => 'hp_featured', 'compare' => 'NOT EXISTS' ) ) ),
'Q10 rating_count != 1' => $L + array( 'meta_query' => array( array( 'key' => 'hp_rating_count', 'value' => '1', 'compare' => '!=' ) ) ),
'Q11 product _price BETWEEN+instock' => array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => 20, 'meta_query' => array( array( 'key' => '_price', 'value' => array( 1000, 5000 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ), array( 'key' => '_stock_status', 'value' => 'instock' ) ) ),
'Q12 request budget > (NUMERIC)' => array( 'post_type' => 'hp_request', 'post_status' => 'publish', 'posts_per_page' => 20, 'meta_query' => array( array( 'key' => 'hp_budget', 'value' => 50000, 'compare' => '>', 'type' => 'NUMERIC' ) ) ),
'Q13 rating_count IN (1,2)' => $L + array( 'meta_query' => array( array( 'key' => 'hp_rating_count', 'value' => array( 1, 2 ), 'compare' => 'IN' ) ) ),
'Q16 price<1000 OR warranty=0 (mix)' => $L + array( 'meta_query' => array( 'relation' => 'OR', array( 'key' => 'hp_price', 'value' => 1000, 'compare' => '<', 'type' => 'NUMERIC' ), array( 'key' => 'hp_warranty', 'value' => '0' ) ) ),
'Q14 price > (CHAR, no type)' => $L + array( 'meta_query' => array( array( 'key' => 'hp_price', 'value' => '9000', 'compare' => '>' ) ) ),
'Q15 hourly_rate < 50 orderby price' => $L + array( 'meta_query' => array( 'rate' => array( 'key' => 'hp_hourly_rate', 'value' => 50, 'compare' => '<', 'type' => 'NUMERIC' ), 'price' => array( 'key' => 'hp_price', 'type' => 'NUMERIC', 'compare' => 'EXISTS' ) ), 'orderby' => 'price', 'order' => 'ASC' ),
);
$res = array();
foreach ( $cases as $name => $qa ) {
$times = array();
$ids = null;
$found = null;
$err = '';
$sql = '';
for ( $i = 0; $i < $iters; $i++ ) {
wp_cache_flush_group( 'tmdo_found_rows' );
$wpdb->last_error = '';
$t = microtime( true );
$q = new WP_Query( $qa + array( 'fields' => 'ids', 'cache_results' => false, 'suppress_filters' => false ) );
$times[] = ( microtime( true ) - $t ) * 1000;
$ids = array_map( 'intval', $q->posts );
$found = (int) $q->found_posts;
$sql = $q->request;
if ( $wpdb->last_error ) { $err = $wpdb->last_error; }
}
sort( $times );
$res[ $name ] = array(
'found' => $found,
'ids' => $ids,
'p50' => round( $times[ (int) floor( count( $times ) / 2 ) ], 2 ),
'max' => round( max( $times ), 2 ),
'err' => $err,
'sql' => preg_replace( '/\s+/', ' ', $sql ),
);
printf( "%-36s found=%-6d p50=%8.2fms %s\n", $name, $found, $res[ $name ]['p50'], $err ? "ERR: $err" : '' );
}
file_put_contents( $out, wp_json_encode( array( 'mode' => TMDO_Mode_Manager::get( 'post' ), 'res' => $res ), JSON_PRETTY_PRINT ) );
echo "saved $out (post mode=" . TMDO_Mode_Manager::get( 'post' ) . ")\n";
+11
View File
@@ -0,0 +1,11 @@
<?php
// php qdiff.php a.json b.json — compare two qbench runs (a = native reference).
$a = json_decode( file_get_contents( $argv[1] ), true ); $b = json_decode( file_get_contents( $argv[2] ), true );
printf( "A=%s (%s) B=%s (%s)\n", basename( $argv[1] ), $a['mode'], basename( $argv[2] ), $b['mode'] );
printf( "%-36s %8s %8s %6s %6s %9s %9s %7s\n", 'case', 'foundA', 'foundB', 'set', 'order', 'p50A', 'p50B', 'speedup' );
foreach ( $a['res'] as $k => $ra ) {
$rb = $b['res'][ $k ];
$set = ( array_diff( $ra['ids'], $rb['ids'] ) || array_diff( $rb['ids'], $ra['ids'] ) ) ? 'DIFF' : 'same';
$ord = $ra['ids'] === $rb['ids'] ? 'same' : 'DIFF';
printf( "%-36s %8d %8d %6s %6s %9.2f %9.2f %6.1fx %s\n", $k, $ra['found'], $rb['found'], $set, $ord, $ra['p50'], $rb['p50'], $rb['p50'] > 0 ? $ra['p50'] / $rb['p50'] : 0, $rb['err'] ? 'ERR:' . substr( $rb['err'], 0, 60 ) : '' );
}
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# usage: run_batch.sh <batch-tag> <scale-divisor>
S="$(cd "$(dirname "$0")" && pwd)/seed.php"
cd "${TMDO_LIVE_WP_PATH:-/var/www/Studio/wp-local-dev36}"
B=$1; D=${2:-1}; OUT=${TMDO_LIVE_OUT:-/tmp}; LOG=$OUT/batch_$B.log
run(){ wp eval-file $S "$@" 2>>$OUT/batch_${B}_err.log | grep SEED >> $LOG; }
echo "== start $(date +%T)" >> $LOG
run terms $((100/D)) ${B}
for p in 1 2 3 4; do run vendors $((250/D)) ${B}p$p & done; wait
for p in 1 2 3 4; do run listings $((1000/D)) ${B}p$p & done
run requests $((500/D)) ${B} & run products $((1000/D)) ${B} & wait
for p in 1 2; do run reviews $((2000/D)) ${B}p$p & done
for p in 1 2; do run orders $((750/D)) ${B}p$p & done; wait
echo "== end $(date +%T)" >> $LOG
+158
View File
@@ -0,0 +1,158 @@
<?php
/**
* dev36 integration seeder — goes through the real APIs (HivePress models, WC CRUD,
* wp_insert_user / update_*_meta) so every write passes the Hook Bus exactly like prod.
*
* Usage: wp eval-file seed.php <kind> <count> [tag]
* kind: vendors | listings | requests | reviews | products | orders | terms
*/
use HivePress\Models;
add_filter( 'pre_wp_mail', '__return_true' );
// WC Tracks pings pixel.wp.com ~3x per product save (~180 ms each); irrelevant to the test.
add_filter( 'pre_http_request', function ( $r, $a, $url ) { return false !== strpos( $url, 'pixel.wp.com' ) ? array( 'headers' => array(), 'body' => '', 'response' => array( 'code' => 200, 'message' => 'OK' ), 'cookies' => array() ) : $r; }, 1, 3 );
wp_defer_term_counting( true );
wp_defer_comment_counting( true );
$kind = $args[0] ?? '';
$count = (int) ( $args[1] ?? 0 );
$tag = $args[2] ?? 'b1';
mt_srand( crc32( $kind . $tag ) );
$cities = array( 'Taipei', 'Taichung', 'Kaohsiung', 'Tainan', 'Hsinchu', 'Keelung' );
function s36_pick_ids( $post_type, $limit = 5000 ) {
global $wpdb;
return array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type=%s AND post_status='publish' ORDER BY RAND() LIMIT %d", $post_type, $limit ) ) );
}
$t0 = microtime( true );
$q0 = get_num_queries();
$ok = 0;
switch ( $kind ) {
case 'vendors':
for ( $i = 0; $i < $count; $i++ ) {
$login = "s36v_{$tag}_{$i}";
$uid = wp_insert_user( array( 'user_login' => $login, 'user_email' => "$login@example.test", 'user_pass' => wp_generate_password(), 'role' => 'contributor', 'first_name' => "F$i", 'last_name' => "L$i", 'description' => "Vendor $i bio" ) );
if ( is_wp_error( $uid ) ) { continue; }
update_user_meta( $uid, 'billing_city', $cities[ $i % 6 ] );
update_user_meta( $uid, 'billing_phone', '09' . mt_rand( 10000000, 99999999 ) );
update_user_meta( $uid, 'facebook', "https://facebook.com/$login" );
update_user_meta( $uid, 'login_count', mt_rand( 1, 300 ) );
$v = ( new Models\Vendor() )->fill( array( 'name' => "Vendor $tag $i", 'description' => 'Seeded vendor', 'status' => 'publish', 'user' => $uid, 'verified' => (bool) mt_rand( 0, 1 ) ) );
if ( $v->save() ) {
update_post_meta( $v->get_id(), 'hp_hourly_rate', mt_rand( 10, 300 ) );
update_post_meta( $v->get_id(), 'hp_rating', mt_rand( 10, 50 ) / 10 );
$ok++;
}
}
break;
case 'listings':
global $wpdb;
$vendors = $wpdb->get_results( "SELECT ID, post_author FROM {$wpdb->posts} WHERE post_type='hp_vendor' AND post_status='publish'" );
$cats = get_terms( array( 'taxonomy' => 'hp_listing_category', 'hide_empty' => false, 'fields' => 'ids' ) );
for ( $i = 0; $i < $count; $i++ ) {
$v = $vendors[ array_rand( $vendors ) ];
$l = ( new Models\Listing() )->fill( array(
'title' => "Listing $tag $i " . $cities[ $i % 6 ],
'description' => str_repeat( 'Seeded listing body. ', 8 ),
'status' => ( mt_rand( 1, 10 ) <= 9 ) ? 'publish' : 'draft',
'user' => (int) $v->post_author,
'vendor' => (int) $v->ID,
'featured' => mt_rand( 1, 20 ) === 1,
'verified' => mt_rand( 1, 3 ) === 1,
'categories' => $cats ? array( $cats[ array_rand( $cats ) ] ) : array(),
) );
if ( $l->save() ) {
$id = $l->get_id();
update_post_meta( $id, 'hp_price', mt_rand( 100, 50000 ) );
update_post_meta( $id, 'hp_hourly_rate', mt_rand( 10, 300 ) );
update_post_meta( $id, 'hp_warranty', mt_rand( 0, 24 ) );
$ok++;
}
}
break;
case 'requests':
$users = get_users( array( 'fields' => 'ID', 'number' => 3000, 'orderby' => 'rand' ) );
for ( $i = 0; $i < $count; $i++ ) {
$r = ( new Models\Request() )->fill( array( 'title' => "Request $tag $i", 'description' => 'Need help', 'status' => 'publish', 'user' => (int) $users[ array_rand( $users ) ] ) );
if ( $r->save() ) {
update_post_meta( $r->get_id(), 'hp_budget', mt_rand( 500, 90000 ) );
update_post_meta( $r->get_id(), 'hp_due_date', gmdate( 'Y-m-d', time() + mt_rand( 1, 90 ) * DAY_IN_SECONDS ) );
$ok++;
}
}
break;
case 'reviews':
$listings = s36_pick_ids( 'hp_listing' );
$users = get_users( array( 'fields' => array( 'ID', 'display_name', 'user_email' ), 'number' => 3000, 'orderby' => 'rand' ) );
for ( $i = 0; $i < $count; $i++ ) {
$u = $users[ array_rand( $users ) ];
$rv = ( new Models\Review() )->fill( array( 'text' => "Review $tag $i", 'rating' => mt_rand( 1, 5 ), 'approved' => 1, 'author' => (int) $u->ID, 'author__display_name' => $u->display_name, 'author__email' => $u->user_email, 'listing' => $listings[ array_rand( $listings ) ] ) );
if ( $rv->save() ) { $ok++; }
}
break;
case 'terms':
for ( $i = 0; $i < $count; $i++ ) {
$t = wp_insert_term( "Cat $tag $i", 'hp_listing_category' );
if ( ! is_wp_error( $t ) ) {
update_term_meta( $t['term_id'], 'hp_sort_order', $i );
update_term_meta( $t['term_id'], 'hp_icon', 'star' );
$ok++;
}
}
break;
case 'products':
$pcats = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => false, 'fields' => 'ids' ) );
for ( $i = 0; $i < $count; $i++ ) {
$p = new WC_Product_Simple();
$p->set_name( "Product $tag $i" );
$p->set_status( 'publish' );
$reg = mt_rand( 100, 20000 );
$p->set_regular_price( (string) $reg );
if ( mt_rand( 1, 4 ) === 1 ) { $p->set_sale_price( (string) (int) ( $reg * 0.8 ) ); }
$p->set_sku( "S36-$tag-$i" );
$p->set_manage_stock( true );
$p->set_stock_quantity( mt_rand( 0, 500 ) );
if ( $pcats ) { $p->set_category_ids( array( $pcats[ array_rand( $pcats ) ] ) ); }
if ( $p->save() ) { $ok++; }
}
break;
case 'orders':
$products = wc_get_products( array( 'limit' => 2000, 'status' => 'publish', 'orderby' => 'rand', 'return' => 'ids' ) );
$users = get_users( array( 'fields' => 'ID', 'number' => 3000, 'orderby' => 'rand' ) );
$statuses = array( 'completed', 'completed', 'processing', 'on-hold', 'cancelled', 'refunded' );
for ( $i = 0; $i < $count; $i++ ) {
$o = wc_create_order( array( 'customer_id' => (int) $users[ array_rand( $users ) ] ) );
if ( is_wp_error( $o ) ) { continue; }
$n = mt_rand( 1, 3 );
for ( $j = 0; $j < $n; $j++ ) { $o->add_product( wc_get_product( $products[ array_rand( $products ) ] ), mt_rand( 1, 3 ) ); }
$o->set_billing_city( $cities[ $i % 6 ] );
$o->set_billing_email( "buyer$i@example.test" );
$o->calculate_totals( false );
$o->set_status( $statuses[ array_rand( $statuses ) ] );
$o->save();
$ok++;
}
break;
default:
WP_CLI::error( "unknown kind $kind" );
}
wp_defer_term_counting( false );
wp_defer_comment_counting( false );
$el = microtime( true ) - $t0;
$q = get_num_queries() - $q0;
WP_CLI::log( sprintf( 'SEED %s x%d ok=%d %.1fs %.2f ms/item %.1f queries/item mode(post=%s,user=%s,term=%s,comment=%s)',
$kind, $count, $ok, $el, $ok ? $el * 1000 / $ok : 0, $ok ? $q / $ok : 0,
...( class_exists( 'TMDO_Mode_Manager' ) ? array( TMDO_Mode_Manager::get( 'post' ), TMDO_Mode_Manager::get( 'user' ), TMDO_Mode_Manager::get( 'term' ), TMDO_Mode_Manager::get( 'comment' ) ) : array( 'NO-TMDO', '-', '-', '-' ) ) ) );
+22
View File
@@ -0,0 +1,22 @@
<?php
/**
* TEMPORARY test probe for the 2meet-DO integration test (2026-09-26). Delete after testing.
* Only active when the request carries header "X-S36-Probe: 1".
*/
if ( empty( $_SERVER['HTTP_X_S36_PROBE'] ) ) {
return;
}
add_action(
'template_redirect',
function () {
global $wp_query, $wpdb;
$ids = wp_list_pluck( (array) $wp_query->posts, 'ID' );
header( 'X-S36-Found: ' . (int) $wp_query->found_posts );
header( 'X-S36-Ids: ' . implode( ',', array_map( 'intval', $ids ) ) );
header( 'X-S36-Queries: ' . (int) $wpdb->num_queries );
header( 'X-S36-Routed: ' . ( false !== strpos( (string) $wp_query->request, 'wpdo' ) ? 1 : 0 ) );
header( 'X-S36-Sql: ' . substr( preg_replace( '/\s+/', ' ', (string) $wp_query->request ), 0, 1500 ) );
header( 'X-S36-Ms: ' . round( ( microtime( true ) - $_SERVER['REQUEST_TIME_FLOAT'] ) * 1000 ) );
},
0
);
+61 -1
View File
@@ -11,17 +11,35 @@ class CacheLayerTest extends TestCase {
/** Rows returned by get_results() for the bulk-fetch query. */
public static array $db_rows = [];
/** Whether SHOW TABLES reports the cold table. */
public static bool $table_exists = true;
/** Queries that read the cold table (get_var / get_results on it). */
public static int $cold_queries = 0;
protected function setUp(): void {
self::$db_rows = [];
self::$table_exists = true;
self::$cold_queries = 0;
$GLOBALS['_wp_cache'] = [];
// Reset Schema Registry singleton.
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
$ref->getProperty( 'instance' )->setValue( null, null );
// The cold module is write-active unless a test says otherwise.
$this->set_cold_module( 'dual_write' );
WPDO_Schema_Manager::flush_table_exists_cache();
$this->setup_wpdb_mock();
}
private function set_cold_module( string $state ): void {
$GLOBALS['_wp_options']['wpdo_features'] = array( 'cold_hp_listing' => $state );
$prop = new ReflectionProperty( WPDO_Feature_Flags::class, 'cache' );
$prop->setValue( null, null );
}
private function setup_wpdb_mock(): void {
global $wpdb;
@@ -36,7 +54,15 @@ class CacheLayerTest extends TestCase {
}, $sql );
}
public function get_var( string $sql ): ?string { return null; }
public function get_var( string $sql ): ?string {
if ( preg_match( "/^SHOW TABLES LIKE '(.+)'$/", $sql, $m ) ) {
return CacheLayerTest::$table_exists ? stripslashes( $m[1] ) : null;
}
if ( str_contains( $sql, 'wpdo_cold_' ) ) {
++CacheLayerTest::$cold_queries;
}
return null;
}
public function get_row( string $sql, $output = OBJECT ) { return null; }
public function insert( string $table, array $data, $format = null ): int|false { return 1; }
public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; }
@@ -44,11 +70,45 @@ class CacheLayerTest extends TestCase {
public function query( string $sql ): int|bool { return 1; }
public function get_results( string $sql, $output = OBJECT ): array {
if ( str_contains( $sql, 'wpdo_cold_' ) ) {
++CacheLayerTest::$cold_queries;
}
return CacheLayerTest::$db_rows;
}
};
}
// ── Zero side effect while the cold module is not active ─────────────────
public function test_prefetch_does_not_query_while_module_idle(): void {
$this->register_cold_field();
$this->set_cold_module( 'idle' );
WPDO_Cache_Layer::prefetch( [ 1, 2 ], 'hp_listing' );
$this->assertSame( 0, self::$cold_queries );
$this->assertEmpty( $GLOBALS['_wp_cache'] );
}
public function test_warm_post_does_not_query_while_module_idle(): void {
$this->register_cold_field();
$this->set_cold_module( 'idle' );
WPDO_Cache_Layer::warm_post( 20, 'hp_listing' );
$this->assertSame( 0, self::$cold_queries );
}
public function test_no_query_when_cold_table_is_missing(): void {
$this->register_cold_field();
self::$table_exists = false;
WPDO_Cache_Layer::prefetch( [ 1 ], 'hp_listing' );
WPDO_Cache_Layer::warm_post( 1, 'hp_listing' );
$this->assertSame( 0, self::$cold_queries );
}
// ── Helper: register cold field ──────────────────────────────────────────
private function register_cold_field( string $post_type = 'hp_listing', string $meta_key = 'hp_description' ): void {
+13
View File
@@ -43,6 +43,19 @@ class DeprecatedAddonsNoticeTest extends TestCase {
$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 {
ob_start();
TMDO_Deprecated_Addons_Notice::render();
+55
View File
@@ -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 {
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 );
$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 {
WPDO_Member_Fields::register_entity_fields();
@@ -148,41 +154,6 @@ class MemberFieldsRegistrationTest extends TestCase {
$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 ──────────────────────────────────────────
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() );
}
/**
* @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
* @preserveGlobalState disabled
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Promotion Guard: HivePress / WooCommerce read native meta directly
* (empty-key get_*_meta(), direct SQL), so on those sites native meta must
* stay authoritative — no aeav_only, no cleanup of native rows.
*
* Tests that declare HivePress / WooCommerce stand-in classes run in their own
* process so those classes do not leak into other tests.
*/
class PromotionGuardTest extends TestCase {
protected function setUp(): void {
if ( ! class_exists( 'TMDO_Promotion_Guard_Notice' ) ) {
require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php';
}
// Mode changes log through error_log(); in a separate process PHPUnit
// treats that stderr output as an error.
ini_set( 'error_log', '/dev/null' );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_filter_callbacks'] = array();
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
TMDO_Mode_Manager::reset_cache();
}
private function modes( array $modes ): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes;
TMDO_Mode_Manager::reset_cache();
}
public function test_without_integrations_aeav_only_is_allowed(): void {
$this->modes( array( 'post' => 'shadow_read' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
$this->assertNull( TMDO_Promotion_Guard::check_cleanup( 'post' ) );
$this->assertSame( array(), TMDO_Promotion_Guard::violations() );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_hivepress_refuses_aeav_only_for_every_entity(): void {
eval( 'namespace HivePress; class Core {}' );
foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) {
$this->modes( array( $type => 'shadow_read' ) );
$result = TMDO_Mode_Manager::set( $type, 'aeav_only' );
$this->assertInstanceOf( WP_Error::class, $result, $type );
$this->assertSame( 'promotion_guarded', $result->get_error_code(), $type );
$this->assertStringContainsString( 'HivePress', $result->get_error_message(), $type );
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( $type ), "{$type} mode must not change" );
$this->assertSame( array( $type => 'shadow_read' ), $GLOBALS['_wp_options']['wpdo_bridge_modes'], "{$type} option must not change" );
}
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_woocommerce_guards_post_and_user_only(): void {
eval( 'class WooCommerce {}' );
$this->modes( array( 'post' => 'shadow_read', 'user' => 'shadow_read', 'term' => 'shadow_read' ) );
$this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
$this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'user', 'aeav_only' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'term', 'aeav_only' ) );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_guard_allows_lower_modes_and_demotion(): void {
eval( 'namespace HivePress; class Core {}' );
$this->modes( array( 'post' => 'disabled' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'dual_write' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'shadow_read' ) );
$this->modes( array( 'post' => 'aeav_only' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'dual_write' ), 'the way out of aeav_only stays open' );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_filter_can_allow_a_guarded_promotion(): void {
eval( 'namespace HivePress; class Core {}' );
add_filter(
'tmdo_promotion_guard_allow',
static function ( $allow, $entity_type, $action, $integrations ) {
return 'user' === $entity_type && 'aeav_only' === $action && array( 'HivePress' ) === $integrations;
},
10,
4
);
$this->modes( array( 'user' => 'shadow_read', 'post' => 'shadow_read' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'user', 'aeav_only' ) );
$this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_cleanup_is_refused_even_in_aeav_only(): void {
eval( 'namespace HivePress; class Core {}' );
$this->modes( array( 'post' => 'aeav_only', 'user' => 'aeav_only' ) );
$error = TMDO_Promotion_Guard::check_cleanup( 'post' );
$this->assertInstanceOf( WP_Error::class, $error );
try {
TMDO_Post_Migration::cleanup();
$this->fail( 'post cleanup must be refused' );
} catch ( RuntimeException $e ) {
$this->assertStringContainsString( 'HivePress', $e->getMessage() );
}
$job = array( 'log' => array(), 'options' => array() );
try {
( new TMDO_Phase_Cleanup( 'user' ) )->execute( $job );
$this->fail( 'user cleanup phase must be refused' );
} catch ( RuntimeException $e ) {
$this->assertStringContainsString( 'HivePress', $e->getMessage() );
}
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_sites_already_in_aeav_only_are_reported(): void {
eval( 'class WooCommerce {}' );
$this->modes( array( 'post' => 'aeav_only', 'term' => 'aeav_only' ) );
$this->assertSame( array( 'post' => array( 'WooCommerce' ) ), TMDO_Promotion_Guard::violations() );
ob_start();
TMDO_Promotion_Guard_Notice::render();
$html = (string) ob_get_clean();
$this->assertStringContainsString( 'post', $html );
$this->assertStringContainsString( 'bridge-restore-native post', $html );
}
public function test_notice_is_silent_without_violations(): void {
$this->modes( array( 'post' => 'aeav_only' ) );
ob_start();
TMDO_Promotion_Guard_Notice::render();
$this->assertSame( '', ob_get_clean() );
}
}
+59 -1
View File
@@ -95,11 +95,13 @@ class RestApiTest extends TestCase {
'zone' => 'hot',
'column' => 'hp_price',
'type' => 'decimal',
'show_in_rest' => true,
] );
$registry->register( 'test', [
'post_type' => 'hp_listing',
'meta_key' => 'hp_description',
'zone' => 'cold',
'show_in_rest' => true,
] );
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' );
@@ -115,6 +117,62 @@ class RestApiTest extends TestCase {
$this->assertSame( 'Test desc', $data['hp_description'] );
}
// ── public field exposure (1.1.1) ─────────────────────────────────────────
/**
* Register fields on hp_listing #43 and GET it anonymously.
*
* @param array<int, array<string, mixed>> $fields Field configs (post_type added).
* @param array<string, string> $meta Stored meta.
* @return array<string, mixed> Response data.
*/
private function listing_with( array $fields, array $meta ): array {
$GLOBALS['_wp_post_types'][43] = 'hp_listing';
$GLOBALS['_wp_postmeta'][43] = $meta;
foreach ( $fields as $field ) {
WPDO_Schema_Registry::instance()->register( 'test', $field + array( 'post_type' => 'hp_listing' ) );
}
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/43' );
$req->set_param( 'id', 43 );
return $this->api->get_listing( $req )->get_data();
}
public function test_registered_fields_are_not_public_unless_declared(): void {
$data = $this->listing_with(
array(
array( 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price' ),
array( 'meta_key' => 'hp_notes', 'zone' => 'cold' ),
),
array( 'hp_price' => '500', 'hp_notes' => 'internal' )
);
$this->assertSame( array( 'id', 'post_type' ), array_keys( $data ) );
}
public function test_protected_and_credential_keys_stay_private_even_when_declared_public(): void {
$data = $this->listing_with(
array(
array( 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ),
array( 'meta_key' => '_purchase_note', 'zone' => 'cold', 'show_in_rest' => true ),
array( 'meta_key' => 'tmeetic_ig_access_token_enc', 'zone' => 'cold', 'show_in_rest' => true ),
array( 'meta_key' => 'hp_api_key', 'zone' => 'hot', 'column' => 'hp_api_key', 'show_in_rest' => true ),
array( 'meta_key' => 'hp_client_secret', 'zone' => 'cold', 'show_in_rest' => true ),
),
array( 'hp_price' => '500', '_purchase_note' => 'x', 'tmeetic_ig_access_token_enc' => 'y', 'hp_api_key' => 'z', 'hp_client_secret' => 'w' )
);
$this->assertSame( array( 'id', 'post_type', 'hp_price' ), array_keys( $data ) );
}
public function test_rest_visible_columns_exclude_protected_and_credential_keys(): void {
$registry = WPDO_Schema_Registry::instance();
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) );
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => '_hp_token', 'zone' => 'hot', 'column' => 'hp_token', 'show_in_rest' => true ) );
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_draft', 'zone' => 'hot', 'column' => 'hp_draft' ) );
$this->assertSame( array( 'hp_price' ), $registry->get_rest_visible_hot_columns( 'hp_listing' ) );
}
// ── get_stats ─────────────────────────────────────────────────────────────
public function test_get_stats_404_when_post_not_found(): void {
@@ -185,7 +243,7 @@ class RestApiTest extends TestCase {
public function test_listings_fallback_only_returns_show_in_rest_columns(): void {
$registry = WPDO_Schema_Registry::instance();
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price' ) );
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) );
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_secret', 'zone' => 'hot', 'column' => 'hp_secret', 'show_in_rest' => false ) );
$post = new stdClass();
+24
View File
@@ -18,6 +18,30 @@ class SchemaRegistryTest extends TestCase {
$instance->setValue( null, null );
$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() ──────────────────────────────────────────────────────────
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* text / textarea fields holding arrays (e.g. usermeta wp_persisted_preferences,
* community-events-location) were cast with (string) and stored as "Array";
* restoring native meta then wrote "Array" back over the real data.
*/
class TypeCasterSerializeTest extends TestCase {
/** Rows passed to $wpdb->insert() (wpdo_errors via TMDO_Logger::warning). */
public static array $inserts = array();
private const TEXT = array( 'type' => 'text', 'group' => 'admin_prefs', 'key' => 'community-events-location' );
private const TEXTAREA = array( 'type' => 'textarea', 'group' => 'admin_prefs', 'key' => 'wp_persisted_preferences' );
protected function setUp(): void {
self::$inserts = array();
ini_set( 'error_log', '/dev/null' );
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function insert( string $table, array $data, $format = null ) {
TypeCasterSerializeTest::$inserts[] = array( $table, $data );
return 1;
}
};
}
public static function arrays(): array {
return array(
'flat' => array( array( 'ip' => '118.163.181.0' ) ),
'nested' => array( array( 'core/edit-post' => array( 'welcomeGuide' => false, 'panels' => array( 'a', 'b' ) ) ) ),
'empty' => array( array() ),
'list' => array( array( 1, 2, 3 ) ),
);
}
/**
* @dataProvider arrays
*/
public function test_text_array_round_trips( array $value ): void {
$stored = TMDO_Type_Caster::to_db( $value, self::TEXT );
$this->assertNotSame( 'Array', $stored );
$this->assertSame( serialize( $value ), $stored );
$this->assertSame( $value, TMDO_Type_Caster::from_db( $stored, self::TEXT ) );
}
/**
* @dataProvider arrays
*/
public function test_textarea_array_round_trips( array $value ): void {
$stored = TMDO_Type_Caster::to_db( $value, self::TEXTAREA );
$this->assertSame( serialize( $value ), $stored );
$this->assertSame( $value, TMDO_Type_Caster::from_db( $stored, self::TEXTAREA ) );
}
public function test_object_is_serialized_not_cast(): void {
$value = (object) array( 'a' => 1 );
$this->assertSame( serialize( $value ), TMDO_Type_Caster::to_db( $value, self::TEXTAREA ) );
}
public function test_plain_strings_are_unchanged(): void {
foreach ( array( 'fresh', '', 'a:b', '0' ) as $s ) {
$this->assertSame( $s, TMDO_Type_Caster::to_db( $s, self::TEXT ) );
$this->assertSame( $s, TMDO_Type_Caster::from_db( $s, self::TEXT ) );
}
$this->assertSame( '42', TMDO_Type_Caster::to_db( 42, self::TEXT ) );
}
public function test_text_over_255_chars_is_stored_as_null_and_logged(): void {
$long = str_repeat( 'é', 256 );
$this->assertNull( TMDO_Type_Caster::to_db( $long, self::TEXT ) );
$this->assertCount( 1, self::$inserts );
$this->assertStringContainsString( 'text_overflow', self::$inserts[0][1]['hook'] );
$this->assertStringContainsString( 'community-events-location', (string) self::$inserts[0][1]['context'] );
}
public function test_text_of_exactly_255_chars_is_kept(): void {
$max = str_repeat( 'é', 255 );
$this->assertSame( $max, TMDO_Type_Caster::to_db( $max, self::TEXT ) );
$this->assertCount( 0, self::$inserts );
}
public function test_serialized_array_over_255_chars_overflows_too(): void {
$value = array( 'k' => str_repeat( 'x', 300 ) );
$this->assertNull( TMDO_Type_Caster::to_db( $value, self::TEXT ) );
$this->assertSame( serialize( $value ), TMDO_Type_Caster::to_db( $value, self::TEXTAREA ), 'textarea is TEXT, no 255 limit' );
}
}
+61 -13
View File
@@ -21,9 +21,69 @@ class ZoneRouterTest extends TestCase {
self::$last_query = '';
self::$last_delete = [];
self::$get_var_return = null;
WPDO_Schema_Manager::flush_table_exists_cache();
$this->setup_wpdb_mock();
}
/**
* $wpdb mock recording delete() tables; SHOW TABLES reports $existing.
*
* @param string[] $existing Tables that exist.
*/
private function delete_logger( array &$captured, array $existing ): void {
global $wpdb;
$wpdb = new class( $captured, $existing ) {
public string $prefix = 'wp_';
public array $log;
public function __construct( array &$ref, private array $existing ) { $this->log = &$ref; }
public function prepare( string $s, mixed ...$a ): string { return str_replace( '%s', (string) ( $a[0] ?? '' ), $s ); }
public function query( string $s ): int { return 1; }
public function get_var( string $s ): mixed {
foreach ( $this->existing as $t ) {
if ( "SHOW TABLES LIKE {$t}" === $s ) {
return $t;
}
}
return null;
}
public function insert( string $t, array $d, mixed $f = null ): int { return 1; }
public function delete( string $table, array $where, array $formats ): void {
$this->log[] = $table;
}
};
}
private function register_hot_and_cold(): void {
$sr = new ReflectionClass( WPDO_Schema_Registry::class );
$sr->getProperty( 'instance' )->setValue( null, null );
WPDO_Schema_Registry::instance()->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'type' => 'decimal' ) );
WPDO_Schema_Registry::instance()->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_images', 'zone' => 'cold' ) );
}
/** Registered fields do not mean the zone tables exist (zero side effect). */
public function test_delete_post_skips_hot_and_cold_tables_that_do_not_exist(): void {
$this->register_hot_and_cold();
$captured = array();
$this->delete_logger( $captured, array() );
WPDO_Zone_Router::delete_post( 3, 'hp_listing' );
$this->assertNotContains( 'wp_wpdo_hot_hp_listing', $captured );
$this->assertNotContains( 'wp_wpdo_cold_hp_listing', $captured );
$this->assertContains( 'wp_wpdo_warm', $captured );
}
public function test_delete_post_deletes_cold_rows_when_the_table_exists(): void {
$this->register_hot_and_cold();
$captured = array();
$this->delete_logger( $captured, array( 'wp_wpdo_cold_hp_listing' ) );
WPDO_Zone_Router::delete_post( 4, 'hp_listing' );
$this->assertContains( 'wp_wpdo_cold_hp_listing', $captured );
$this->assertNotContains( 'wp_wpdo_hot_hp_listing', $captured );
}
private function setup_wpdb_mock(): void {
global $wpdb;
$wpdb = new class {
@@ -204,19 +264,7 @@ class ZoneRouterTest extends TestCase {
) );
$captured = array();
global $wpdb;
$wpdb = new class( $captured ) {
public string $prefix = 'wp_';
public array $log;
public function __construct( array &$ref ) { $this->log = &$ref; }
public function prepare( string $s, mixed ...$a ): string { return $s; }
public function query( string $s ): int { return 1; }
public function get_var( string $s ): mixed { return null; }
public function insert( string $t, array $d, mixed $f = null ): int { return 1; }
public function delete( string $table, array $where, array $formats ): void {
$this->log[] = $table;
}
};
$this->delete_logger( $captured, array( 'wp_wpdo_hot_hp_listing' ) );
WPDO_Zone_Router::delete_post( 10, 'hp_listing' );
+1 -1
View File
@@ -77,7 +77,7 @@ $wpdo_for_each_site = static function ( callable $callback ) use ( $wpdo_site_id
};
// ── Refuse while any site holds data that exists only in wpdo_* tables ───
if ( ! $bootstrap_ok || ! method_exists( 'TMDO_Installer', 'uninstall_blockers' ) ) {
if ( ! $bootstrap_ok ) {
wp_die( esc_html( '2meet Data Optimizer:無法載入資料安全檢查,為避免資料遺失,已停止解除安裝。' ) );
}