88 Commits

Author SHA1 Message Date
wpdev 0c15266034 Merge pull request 'release 1.4.3: 2meet-data-optimizer' (#12) from release/1.4.3 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 12s
Tests / PHP Lint (push) Successful in 26s
Tests / Unit Tests (push) Successful in 49s
Tests / Integration Tests (push) Successful in 2m2s
Tests / PHPCS (push) Successful in 58s
Tests / PHPStan (push) Successful in 58s
2026-10-02 21:45:01 +08:00
wpdev c13691bcea chore(2meet-data-optimizer): 1.4.3 repackage (no functional change)
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 16s
Tests / Unit Tests (pull_request) Successful in 42s
Tests / PHPCS (pull_request) Successful in 41s
Tests / Integration Tests (pull_request) Successful in 1m56s
Tests / PHPStan (pull_request) Successful in 1m1s
Tests / PHP Lint (pull_request) Successful in 8s
Version bump for a re-release packaged alongside the WP-MVB v8 hub (dev39)
acceptance; code is identical to the previous release.
2026-10-02 17:23:21 +08:00
wpdev 04ae4c0d0f Merge pull request 'Release 2.4.2: core 1.4.2 bugfix' (#11) from hardening/2.4.2 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 12s
Tests / PHP Lint (push) Successful in 14s
Tests / Unit Tests (push) Successful in 47s
Tests / Integration Tests (push) Successful in 1m58s
Tests / PHPCS (push) Successful in 49s
Tests / PHPStan (push) Successful in 50s
2026-09-28 18:51:39 +08:00
wpdev 6f117f4e9b fix(2meet-data-optimizer): 1.4.2 acceptance fixes for archive sweep and uninstall
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 21s
Tests / PHP Lint (pull_request) Successful in 18s
Tests / Unit Tests (pull_request) Successful in 41s
Tests / Integration Tests (pull_request) Successful in 1m25s
Tests / PHPCS (pull_request) Successful in 44s
Tests / PHPStan (pull_request) Successful in 47s
- Archive sweep waits until idx_original_meta exists (a plugin update does
  not run dbDelta; without the index NOT EXISTS scans the archive per row)
- Duplicate archive cleanup runs from WP-CLI only, never in a web activation
- Uninstall keeps the LatePoint add-on's tmdo_latepoint_db_version
- AJAX save errors stay on screen instead of fading after 2.5 s
- Verify: 2 new integration tests; unit 711, integration 612, phpcs 0, stan OK
- PR: PR-141 (2.4.2 correctness review)
2026-09-28 18:43:50 +08:00
wpdev c9d5a08a42 chore(2meet-data-optimizer): 1.4.2 release notes and version
- Version 1.4.2 (header, TMDO_VERSION, readme Stable tag and changelog)
- CHANGELOG lists PR-135..139; after upgrade run wp tmdo install once per
  site for the archive index and duplicate cleanup
- PR: PR-140
2026-09-28 18:26:52 +08:00
wpdev cd5da4143e chore(2meet-data-optimizer): uninstall message points to bridge-restore-native; stress tester drops wc_product
- The refused-uninstall notice said no tool writes the data back to native
  tables; wp tmdo bridge-restore-native does.
- The post stress tester no longer maps product to the retired wc_product table.
- Verify: unit 711, integration 611, phpcs 0
- PR: PR-139 (manual F-7, F-8)
2026-09-28 18:26:15 +08:00
wpdev 15096f064f fix(2meet-data-optimizer): deactivate and uninstall clear every scheduled event and tmdo_* option
- One CRON_HOOKS list for deactivate() and uninstall; wp_unschedule_hook() also
  removes events scheduled with args (tmdo_drift_resync, backfill batches).
- Fix the wrong wpdo_user_stress_test_batch name; add migration tick,
  entity backfill, auto-promote and all stress-test batch hooks.
- Uninstall sweeps tmdo_* options and _transient_(timeout_)tmdo_* rows too.
- Verify: InstallerCleanupTest (3 new), unit 711, integration 611, phpcs 0
- PR: PR-138 (manual F-4, F-5)
2026-09-28 18:25:01 +08:00
wpdev 90877b81e0 fix(2meet-data-optimizer): entity health sees a running backfill
- Backfill batches are scheduled with (entity, group) args; the health
  panel looked for (entity) alone, never saw one and kept offering to start
  a backfill, which resets the group's checkpoint
- is_backfill_active() checks every group of the entity
- Unit bootstrap: wp_next_scheduled() stub honours hook + args
- Verify: EntityHealthBackfillTest (2 new); unit 711, phpcs 0, stan OK
- PR: PR-137 (manual F-11)
2026-09-28 18:18:56 +08:00
wpdev 6687d54bef fix(2meet-data-optimizer): archive sweep copies each trashed meta row once
- sweep() skips meta rows already in wpdo_archive (NOT EXISTS on
  original_meta_id): it copied the same trashed meta again every day and
  wpdo_archive has no unique key (dev20: 300 rows for 150 meta rows)
- TMDO_Zone_Archive::remove_duplicates() keeps the first copy per
  original_meta_id (rows without one are left alone); install() runs it,
  so `wp tmdo install` cleans existing sites
- wpdo_archive gets KEY idx_original_meta for the lookup (new installs;
  existing sites via `wp tmdo install`). SCHEMA_VERSION is not bumped, so
  no web request runs dbDelta
- Verify: ZoneArchiveIntegrationTest (2 new); dev20 read-only check: 150 of
  300 rows would be removed; unit 709, integration 608, phpcs 0, stan OK
- PR: PR-136 (manual F-6)
2026-09-28 18:17:47 +08:00
wpdev 86ac84e07c fix(2meet-data-optimizer): show why an admin bridge-mode change was refused
- The settings form and the AJAX section save both went through
  TMDO_Mode_Manager::set() and dropped its WP_Error: an unsafe jump or a
  Promotion Guard refusal still said "saved" while the mode stayed put
- TMDO_Admin::apply_bridge_modes() returns the refusals; the full form keeps
  them in a per-user transient and shows them after the redirect, the AJAX
  save returns them as the error message (the settings JS shows it)
- Verify: AdminBridgeModeSaveTest (5 new); unit 709, integration 606, phpcs 0, stan OK
- PR: PR-135 (manual F-2)
2026-09-28 18:14:35 +08:00
wpdev b6200c581c Merge pull request 'hardening 2.4.1: 2meet-data-optimizer 1.4.1 (acceptance review fixes)' (#10) from hardening/2.4.1 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 51s
Tests / Unit Tests (push) Successful in 47s
Tests / PHP Lint (push) Successful in 1m9s
Tests / Integration Tests (push) Successful in 2m4s
Tests / PHPCS (push) Successful in 1m15s
Tests / PHPStan (push) Successful in 1m14s
2026-09-28 01:09:15 +08:00
wpdev 81324903a6 chore(2meet-data-optimizer): 1.4.1 release notes and version
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 47s
Tests / PHP Lint (pull_request) Successful in 53s
Tests / Unit Tests (pull_request) Successful in 51s
Tests / Integration Tests (pull_request) Successful in 1m54s
Tests / PHPCS (pull_request) Successful in 36s
Tests / PHPStan (pull_request) Successful in 1m12s
- CHANGELOG / readme: PR-130..133 (2.4 acceptance review fixes)
- Version 1.4.0 -> 1.4.1 (header, TMDO_VERSION, Stable tag)
- PR: PR-134
2026-09-28 00:46:56 +08:00
wpdev 3e96357459 fix(2meet-data-optimizer): give new posts' flat rows their status/date copies; sync drops cached counts
- add_flat_rows (wp_insert_post, after transition_post_status) inserts the
  copies with the empty row: a NULL copy turned the copy route off for the
  whole group until the next sync
- wp tmdo post-status-sync bumps the routed count version (not on dry runs)
- Test bootstrap: minimal WP_Post stub
- Found in the 2.4 correctness review (residual risks)
- Verify: 2 new integration tests; integration 606, unit 704, phpcs 0, stan OK
- PR: PR-133
2026-09-28 00:32:59 +08:00
wpdev 80343ec282 fix(2meet-data-optimizer): drop router query vars a caller passed in
- pre_get_posts clears wpdo_post_clauses / _sort / _copy_route / _count_sql
  before anything else: new WP_Query( array( … ) ) could preset them
- posts_orderby whitelists the copy route's direction
- Found in the 2.4 security review (Low L2; not reachable from URLs)
- Verify: PostQueryRouterCopyTest (1 new); integration 604, unit 704, phpcs 0
- PR: PR-132
2026-09-28 00:31:10 +08:00
wpdev 33addd1e49 fix(2meet-data-optimizer): rewrite only standalone posts.ID tie-breaks in ORDER BY
- flat_first and the copy route share ties_on_post_id(): a posts.ID term
  alone (optionally with a direction) reads the flat post_id; posts.ID inside
  an expression a plugin added (FIELD(), IN) is left alone — inserting a
  direction there made the SQL invalid and the page empty
- PCRE failures return the text given instead of '' (the query kept its
  shape rather than becoming an empty request)
- Found in the 2.4 correctness review (Medium) and security review (Low L1)
- Verify: 2 new integration tests (the flat-first one runs the SQL), router suites 69 OK
- PR: PR-131
2026-09-28 00:29:53 +08:00
wpdev bed3f906da fix(2meet-data-optimizer): keep meta_key native when the query still orders by it
- meta_key is routed as EXISTS only when no orderby entry names meta_value,
  meta_value_num or the key (several orderings or an array included);
  clearing it made WordPress drop the price ordering
- A sort group is dropped as unused only when orderby names neither it,
  meta_value(_num) nor its key (it is then the primary clause)
- meta_compare_key other than '=' keeps meta_key native (LIKE matches other keys)
- Found in the 2.4 correctness review (regressions from PR-122)
- Verify: PostQueryRouterTest (3 new), router suites 67 OK
- PR: PR-130
2026-09-28 00:27:56 +08:00
wpdev 7ad65eab95 Merge pull request 'docs: correct the 1.4.0 upgrade step for the copy index' (#9) from docs/1.4.0-upgrade-note into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 11s
Tests / PHP Lint (push) Successful in 22s
Tests / Unit Tests (push) Successful in 1m12s
Tests / Integration Tests (push) Successful in 2m7s
Tests / PHPCS (push) Successful in 1m9s
Tests / PHPStan (push) Successful in 1m10s
2026-09-28 00:08:56 +08:00
wpdev 4b34851316 docs(2meet-data-optimizer): correct the 1.4.0 upgrade step for the copy index
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 25s
Tests / PHP Lint (pull_request) Successful in 55s
Tests / Unit Tests (pull_request) Successful in 56s
Tests / Integration Tests (pull_request) Successful in 1m46s
Tests / PHPCS (pull_request) Successful in 44s
Tests / PHPStan (pull_request) Successful in 1m4s
- Replace the index only on tables listed in tmdo_post_status_columns
  (ALTER ADD idx_post_status_date_id / DROP idx_post_status_date)
- Warn that `wp tmdo post-status-sync` also adds and backfills copy
  columns on every other post group (large ALTER + UPDATE)
- Found in the 2.4 rollout (dev21: aeav_only, over ten million posts)
2026-09-28 00:03:02 +08:00
wpdev b30baeddf0 Merge pull request 'hardening 2.4: 2meet-data-optimizer 1.4.0 (index-ordered routed sorts, date copy route, count invalidation)' (#8) from hardening/2.4 into main
Tests / PHP Lint (push) Successful in 12s
Tests / Unit Tests (push) Successful in 1m10s
Tests / Integration Tests (push) Failing after 2m0s
Tests / PHPCS (push) Successful in 1m31s
Tests / PHPStan (push) Successful in 50s
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Failing after 14m4s
2026-09-27 23:40:30 +08:00
wpdev 13c4a1bcb9 chore(2meet-data-optimizer): 1.4.0 release notes and version
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 27s
Tests / Unit Tests (pull_request) Successful in 44s
Tests / PHP Lint (pull_request) Successful in 27s
Tests / PHPCS (pull_request) Successful in 35s
Tests / Integration Tests (pull_request) Successful in 1m41s
Tests / PHPStan (pull_request) Successful in 1m23s
- CHANGELOG / readme: PR-118, 122, 123, 127, 128, 129; upgrade step
  `wp tmdo post-status-sync` for the three-column copy index
- Version 1.3.0 -> 1.4.0 (header, TMDO_VERSION, Stable tag)
- PR: PR-124
2026-09-27 23:29:11 +08:00
wpdev 692b23aed9 fix(2meet-data-optimizer): drop cached routed counts when the migration engine rebuilds post rows
- migrate_entity (drift-check --fix), migrate_group and migrate_group_batch
  write flat rows outside the Hook Bus: bump the routed count version
  (once per call / batch; not on dry runs; post rows only)
- dev36: a vendor search kept counting 21515 of 21522 matches after a fix
- Verify: composer test (704), test:integration (598), phpcs, stan
- PR: PR-129
2026-09-27 23:25:03 +08:00
wpdev 8ee3dce0fe perf(2meet-data-optimizer): let the status/date copy route stop at the LIMIT
- Copy route: drop WordPress's GROUP BY when the flat row is the only join,
  and read posts.ID tie-breaks as the flat post_id (own direction, once)
- Post status sync: index (post_status, post_date, post_id) replaces the
  two-column idx_post_status_date (added / dropped by post-status-sync)
- dev36, 235k of 1.3M listings in a price range: main query 1.7 s -> 3 ms
- Verify: composer test (704), test:integration (597), phpcs, stan
- PR: PR-128
2026-09-27 23:12:34 +08:00
wpdev 2aa9058ee9 fix(2meet-data-optimizer): keep a post row's status and date copies when the migration engine rebuilds it
- migrate_entity() (drift-check --fix, per-row backfill) REPLACEs the whole
  flat row and left out the post_status / post_date copies, so a rebuilt row
  had NULL status. One such row turns the status/date copy route off for the
  whole group: on dev36 a single row rebuilt by the stage-3 drift fix made the
  date-ordered price search 1.47 s instead of driving from the flat index
- post rows now carry TMDO_Post_Status_Sync::insert_columns(); the bulk pivot
  backfill (ON DUPLICATE KEY UPDATE) was not affected
- Verify: PostStatusSyncTest (existing and new rows keep their copies),
  integration 593, unit 704, phpcs, stan; dev36 post-status-sync + front-end
- PR: PR-127 (found in 2.4 profiling)
2026-09-27 22:58:09 +08:00
wpdev 66db9e4c56 perf(2meet-data-optimizer): route HivePress's featured-listing lookup
- HivePress runs it on every listing search page from its own
  pre_get_posts: get_posts() with the search's meta query (still unrouted,
  including its sort group), meta_key hp_featured and orderby rand. The
  router declined both shapes, so the lookup stayed native: 2.26–2.38 s of a
  2.5 s page at 1.3M listings on dev36
- a sort group (key NOT EXISTS OR key EXISTS) that no orderby uses matches
  every post and is dropped; meta_key without meta_value and without a
  meta_value sort routes as EXISTS (WP_Query turns it into that clause)
- Verify: PostQueryRouterTest (featured lookup routed, meta_key with a value
  untouched), integration 592, unit 704, phpcs, stan; dev36 front-end
- PR: PR-122 (follow-up)
2026-09-27 22:54:17 +08:00
wpdev 2ed48c3ba3 perf(2meet-data-optimizer): route meta clauses without a value as EXISTS
- WP_Meta_Query adds a value condition only when 'value' is set, so a clause
  like array( 'key' => 'hp_featured' ) only tests that the key exists. The
  router read it as "= ''", declined it, and kept the whole query native
- HivePress runs such a query on every listing search page (featured-listing
  lookup in Attribute::set_search_query(): hp_featured plus the search's own
  filters, ORDER BY RAND()); at 1.3M listings on dev36 it took 2.26 s of a
  2.5 s page, routed or not
- Verify: PostQueryRouterTest (key-only clause routes as IS NOT NULL),
  integration 590, unit 704, phpcs, stan; dev36 front-end
- PR: PR-122
2026-09-27 22:46:52 +08:00
wpdev eac0955b76 perf(2meet-data-optimizer): let flat-first sorts use a (column, id) index
- a group index may end with the entity ID column (post_id, ...)
- flat-first rewrites the posts.ID tie-breaks once, in the sort's direction:
  with a (column, post_id) index MySQL returns rows in order instead of
  sorting the whole match. dev36 (1.3M listings), price range sorted by price:
  850 ms filesort -> 2 ms. Ties are ordered by ID the same way as the column
  (natively ID DESC); pagination stays stable, result sets are unchanged
- Verify: EntityRegistryIndexesTest, PostQueryRouterFoundRowsTest (ASC
  tie-break, single tie-break), unit 704, integration 589, phpcs, stan;
  dev36 with the HivePress indexes (PR-119)
- PR: PR-118
2026-09-27 22:40:59 +08:00
wpdev b2725b219d test(2meet-data-optimizer): live tools no longer flush the object cache
- mut.php called wp_cache_flush() after each write and qbench.php
  wp_cache_flush_group() before each run; with Redis Object Cache's selective
  flush both are one server-side Lua scan over the site's keys, which blocked
  the shared Redis for 5–20 s on dev36 (3 M keys) and broke other sites'
  requests (BUSY / read errors)
- mut.php clears the touched post or user caches plus the runtime cache;
  qbench.php bumps the routed count version (posts' last_changed before 1.3.0)
- tests/ is not packaged; plugin code does not flush
2026-09-27 22:38:16 +08:00
wpdev f929165b7b Merge pull request 'hardening 2.3: 2meet-data-optimizer 1.3.0 (flat-table DDL outside web requests, routed count cache, audit fixes)' (#7) from hardening/2.3 into main
Tests / PHP Lint (push) Successful in 11s
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 14s
Tests / Unit Tests (push) Successful in 43s
Tests / PHPStan (push) Failing after 14m14s
Tests / PHPCS (push) Failing after 14m16s
Tests / Integration Tests (push) Failing after 15m11s
2026-09-27 18:19:17 +08:00
wpdev 947e130386 docs(2meet-data-optimizer): add PR-116 and PR-117 to the 1.3.0 release notes
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 24s
Tests / PHP Lint (pull_request) Successful in 40s
Tests / Unit Tests (pull_request) Successful in 41s
Tests / Integration Tests (pull_request) Successful in 1m23s
Tests / PHPCS (pull_request) Successful in 29s
Tests / PHPStan (pull_request) Successful in 1m14s
- routed count cache: placeholder escape and count version group
- PR: PR-101 (follow-up)
2026-09-27 17:58:07 +08:00
wpdev ca2cbdab65 perf(2meet-data-optimizer): keep cached routed counts across unrelated post meta writes
- cached counts followed posts' last_changed, which WordPress moves on any
  post meta write; themes write meta on page views (on dev36 Blocksy saved a
  dynamic-CSS descriptor for each listing a crawler visited), so the cache
  was invalidated every few seconds and keyword/filter searches recounted
- counts now follow their own last_changed group (tmdo_posts), bumped on
  clean_post_cache (post saved or deleted; hooked in every mode) and on flat
  writes through the Hook Bus, plus terms' last_changed; a routed search reads
  no unmanaged meta
- Verify: PostQueryRouterFoundRowsTest (unrelated meta write keeps the count,
  save or flat write recounts), integration 588, unit 703, phpcs, stan;
  dev36 front-end
- PR: PR-117 (2.3 acceptance)
2026-09-27 17:46:54 +08:00
wpdev 7851f150fa perf(2meet-data-optimizer): key the routed count cache on the SQL without wpdb's placeholder escape
- keyword searches carry wpdb's placeholder escape for "%" ({<hash>}), which
  is new in every request, so md5(count SQL) never matched across requests
  and every keyword search recounted (the 2.2 "keyword + filter routed slower
  than native" finding). The key now uses remove_placeholder_escape()
- found in the 2.3 acceptance: dev36 keyword + price routed 6.2 s vs native
  4.9 s; PR-091's in-process check reused one process, one escape
- Verify: PostQueryRouterFoundRowsTest (two escapes, one COUNT), integration
  588, unit 703, phpcs, stan; dev36 front-end
- PR: PR-116 (2.3 acceptance)
2026-09-27 17:39:36 +08:00
wpdev 1835c8776a docs(2meet-data-optimizer): add PR-111, PR-113 and PR-114 to the 1.3.0 release notes
- deferred-upgrade hardening from the acceptance reviews, notice() audit
  trail, known cold-cache version window
- PR: PR-101 (follow-up)
2026-09-27 17:34:19 +08:00
wpdev 1d70d13f45 fix(2meet-data-optimizer): close the gaps in deferred table upgrades found in the 2.3 review
- a deferred group whose stored definition matches again (reverted, or
  upgraded by another process) goes through end_deferral() instead of staying
  native for good (review F2)
- the tmdo_ensure_group_indexes handler upgrades deferred tables itself: with
  ALTERNATE_WP_CRON the event runs after init in a visitor's request (F3)
- a new group table created while the entity writes to flat stays native
  until the drift fix has filled it from native meta; an opted-in HivePress
  attribute no longer routes searches to an empty table (F6)
- direct_read() returns native values for a deferred group (F10); the Hook
  Bus ignores deferral in aeav_only, as the router does
- dbDelta failures: the definition is recorded only when every column exists
  (schema.upgrade_failed otherwise), so a failed ALTER is retried and the
  group is not released onto missing columns
- test bootstrap: get_metadata() reads the real meta table
- Verify: SchemaUpgradeDeferralTest (6), Hook Bus direct read, integration
  587, unit 703, phpcs, stan
- PR: PR-114 (2.3 acceptance correctness review)
2026-09-27 17:30:36 +08:00
wpdev e26aadc4ec fix(2meet-data-optimizer): keep the audit trail of mode changes and snapshot deletions
- TMDO_Logger::notice() is always written to the error log (it had no
  callers); bridge_mode_changed and snapshot_deleted use it. Since PR-095
  info() is silent without WP_DEBUG_LOG, which left no record of who switched
  a mode or deleted a snapshot (security review L1)
- Verify: LoggerTest (notice always logged, info gated), unit 703,
  integration 585, phpcs, stan
- PR: PR-113 (2.3 acceptance security review)
2026-09-27 17:25:56 +08:00
wpdev 29adb05567 fix(2meet-data-optimizer): alter an existing flat table in one process at a time; keep a deferred group native until its drift fix
- security review H1: anonymous /wp-cron.php requests pass ddl_allowed(),
  and dbDelta on an existing table had no lock, so concurrent requests each
  ran the ALTER on a large table. The upgrade now runs under the same MySQL
  GET_LOCK as the index DDL (skipped when another process holds it) and
  re-checks the stored hash once locked; this covers aeav_only too. The table
  build moves unchanged to write_table(); lock()/unlock() are shared
- security review M1: after a deferred upgrade the group stays native until
  tmdo_drift_resync has rebuilt the rows written meanwhile
  (TMDO_Schema_Manager::resynced() from run_scheduled_fix()); with no flat
  writes (disabled) it is released at once
- Verify: SchemaUpgradeDeferralTest (lock held elsewhere, resync release),
  integration 585, unit 702, phpcs, stan
- PR: PR-111 (2.3 acceptance security review)
2026-09-27 17:22:57 +08:00
wpdev 0aec4b9bb6 docs(2meet-data-optimizer): add PR-106 to PR-110 to the 1.3.0 release notes
- Changed: deferred table upgrades (PR-107), index lock on the primary
  (PR-108), cold cache flush by version (PR-110); Fixed: dropped tables in
  post status sync (PR-106)
- Verify: composer test
- PR: PR-101 (follow-up)
2026-09-27 17:12:54 +08:00
wpdev eb1c8b78f4 perf(2meet-data-optimizer): flush the cold cache group without a server-side scan
- TMDO_Cache_Layer::flush_group() bumps the post type's cold cache version
  (option wpdo_cold_cache_version) instead of wp_cache_flush_group(), which
  Redis Object Cache runs as one server-side scan that blocks Redis on a large
  group; entries under older keys expire by their TTL
- TMDO_Zone_Cold::cache_key() builds every cold blob key; version 0 keeps the
  current keys, so upgrading invalidates nothing
- Verify: CacheLayerTest (new keys, no scan, other post types untouched),
  unit 702, integration 583, phpcs, stan
- PR: PR-110
2026-09-27 17:12:14 +08:00
wpdev 06a3594417 fix(2meet-data-optimizer): take the index lock on the primary under read/write splitting
- when $wpdb has send_reads_to_masters() (HyperDB, LudicrousDB), it is called
  before SELECT GET_LOCK, which a replica would take without locking out the
  ALTER on the primary
- Verify: GroupCompositeIndexTest (call order through a wpdb proxy),
  integration 583, unit 701, phpcs, stan
- PR: PR-108
2026-09-27 17:09:10 +08:00
wpdev e18030b206 fix(2meet-data-optimizer): upgrade existing flat tables from WP-CLI or WP-Cron only
- a changed group definition no longer runs dbDelta on an existing table
  inside a web request (seconds to minutes on a large table, and every
  concurrent request tried it): the group is recorded in tmdo_schema_deferred
  and tmdo_ensure_group_indexes is scheduled, whose request upgrades it
- meanwhile the group stays native: Hook Bus field lookups return no field and
  the router treats it as drifting; afterwards a drift fix
  (tmdo_drift_resync) rebuilds the writes that skipped the flat table
- new tables are still created at once; aeav_only upgrades at once (the flat
  table is the only copy); doctor lists deferred groups
- Verify: SchemaUpgradeDeferralTest, Hook Bus and router tests, integration
  582, unit 701, phpcs, stan; dev36 live cycle in the 2.3 acceptance
- PR: PR-107
2026-09-27 17:07:48 +08:00
wpdev 5968bcd95d fix(2meet-data-optimizer): forget post status columns of groups whose flat table is gone
- on_transition() skips a recorded group whose table no longer exists and
  removes it from tmdo_post_status_columns; every post status change used to
  UPDATE the missing table and print a database error (seen on dev36 after a
  throwaway group's table was dropped)
- Verify: PostStatusSyncTest (dropped table), integration 578, unit 701, phpcs, stan
- PR: PR-106
2026-09-27 16:59:08 +08:00
wpdev 303781e68b docs(2meet-data-optimizer): 1.3.0 release notes
- version 1.3.0; CHANGELOG and readme.txt for PR-090a, 091, 093 (verified,
  no change), 094–098, with the post-upgrade steps (first CLI / cron run adds
  the HivePress indexes; INFO lines need WP_DEBUG_LOG)
- Verify: composer test (701)
- PR: PR-101
2026-09-27 16:46:55 +08:00
wpdev d46913de41 chore(2meet-data-optimizer): prefix the global variables in uninstall.php
- $bootstrap_files, $bootstrap_ok, $f, $result, $snapshot_id and $dir become
  $wpdo_* like the file's other globals (WordPress.org Plugin Check)
- Verify: PrefixAllGlobals sniff 0, phpcs, UninstallGuardTest and
  InstallerCleanupTest, unit 701
- PR: PR-098
2026-09-27 16:41:08 +08:00
wpdev 1b50df7cd0 refactor(2meet-data-optimizer): handle admin form posts before the page renders
- the main page's form handlers move unchanged from render_page() to
  handle_post(), hooked on load-{page}: inside render_page() the admin
  header was already sent, so their wp_safe_redirect() calls could not work
- the setup wizard's step 4 snapshot post moves to handle_snapshot_post() on
  its load hook; a failure redirects with snap_failed=1 for the notice
- Verify: unit 701, integration 577, phpcs, stan; dev36 load hook handles a
  posted reset and redirects before output
- PR: PR-097
2026-09-27 16:39:56 +08:00
wpdev a268470b60 fix(2meet-data-optimizer): treat contact and address keys as private in the public REST filter
- rest_exposable() also withholds keys named like an email, phone, address or
  IP, even when a field declares show_in_rest (defence in depth)
- Verify: RestApiTest (private and look-alike public keys), unit 701,
  integration 577, phpcs, stan; dev36 live rest suite
- PR: PR-096
2026-09-27 16:36:51 +08:00
wpdev 1bdd51d9a3 fix(2meet-data-optimizer): log info and notice events only when debug logging is on
- TMDO_Logger::info()/notice() write to the PHP error log only with
  WP_DEBUG_LOG (filter tmdo_log_info); every CLI run and production request
  printed [WPDO][INFO] lines
- debug() keeps its WP_DEBUG gate; warning() and error() are unchanged
- Verify: LoggerTest (2 new), unit 700, integration 577, phpcs, stan
- PR: PR-095
2026-09-27 16:35:13 +08:00
wpdev 22b46ad175 perf(2meet-data-optimizer): stop writing an option for every rate-limited view
- blocked POST /listings/{id}/view requests update wpdo_rl_stats 1 in 10,
  each sample adding 10: anonymous traffic no longer turns 1:1 into DB writes,
  and the admin top list stays representative (unbiased estimate)
- Verify: RestApiTest counts option writes (about 20 for 200 blocked requests),
  unit 698, integration 577, phpcs, stan
- PR: PR-094
2026-09-27 16:33:16 +08:00
wpdev b187c1c4d3 perf(2meet-data-optimizer): count routed searches from a cached base count minus the excluded posts
- a routed search with post__not_in (HivePress sends a random set per page)
  caches the COUNT without the exclusion and subtracts a live primary-key
  count of the excluded posts that match; the total stays exact
- the excluded count drops STRAIGHT_JOIN so MySQL starts from posts.ID
- Verify: PostQueryRouterFoundRowsTest, unit 698, integration 577, phpcs, stan;
  dev36 live router suite and keyword + price search
- PR: PR-091
2026-09-27 16:19:11 +08:00
wpdev ec8d69a6f4 fix(2meet-data-optimizer): add declared flat-table indexes from WP-CLI or WP-Cron only, one process at a time
- ensure_group_indexes() runs the ALTER only under WP-CLI or WP-Cron (filter
  tmdo_schema_ddl_allowed); web requests, including /wp-admin/ before
  auth_redirect() and admin-post.php, schedule tmdo_ensure_group_indexes instead
- MySQL GET_LOCK keeps it to one process (add_option() is INSERT ... ON
  DUPLICATE KEY UPDATE and cannot serve as a lock); released by MySQL if the
  process dies
- the applied signature is recorded only when every ALTER succeeds; failures
  log schema.index_failed and stay in pending_indexes(), which doctor reports
- new tables get their declared cidx_ indexes in CREATE TABLE
- test stub wpdb::query()/insert() return false on SQL errors like wpdb
- Verify: GroupCompositeIndexTest 7/7, unit 698, integration 576, phpcs, stan
- PR: PR-090a
2026-09-27 16:07:23 +08:00
wpdev 28ac6e3cad Merge pull request 'hardening 2.2: 2meet-data-optimizer 1.2.0 (drift detection, scope, CLI and diagnostics)' (#6) from hardening/2.2 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 12s
Tests / Unit Tests (push) Successful in 40s
Tests / PHP Lint (push) Successful in 55s
Tests / Integration Tests (push) Successful in 1m42s
Tests / PHPCS (push) Successful in 33s
Tests / PHPStan (push) Successful in 46s
2026-09-27 10:56:47 +08:00
wpdev 582ff13a11 test(2meet-data-optimizer): compare site-wide rating sorts by sort key, not id
Tests / PHP Lint (pull_request) Successful in 16s
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 16s
Tests / Unit Tests (pull_request) Successful in 45s
Tests / PHPCS (pull_request) Successful in 35s
Tests / Integration Tests (pull_request) Successful in 1m38s
Tests / PHPStan (pull_request) Successful in 1m18s
- The PR-079 live router cases ran the HivePress rating sort on the whole site with an unqualified DECIMAL: native rounds 4.5 to 5 and orders its many ties by chance, so the id order passed or failed with the query plan. They now send DECIMAL(10,1), as the HivePress Rating field does, and compare each position's native sort key and found_posts; the flat-first / GROUP BY assertions stay.
- Verify: live router 18/18 twice; all suites 56/56
- PR: PR-089b
2026-09-27 10:20:30 +08:00
wpdev edf9e6bc93 fix(2meet-data-optimizer): doctor ignores drift records of groups no longer registered
- A group's last drift record outlived its registration (wc_product and user hot on dev36, any add-on deactivated): doctor counted it and could warn forever with a --fix hint that cannot reach it. Only records of backfillable groups are read; the router and clean_since already go by the registry.
- Verify: DoctorTest (RED before); dev36 doctor user 9 → 8 groups
- PR: PR-089a
2026-09-27 10:07:02 +08:00
wpdev fab07655a2 docs(2meet-data-optimizer): 1.2.0 release notes
- Bump to 1.2.0; CHANGELOG / readme entries for PR-066–080, with the post-upgrade ops commands
- ADR-004 Release 2.2 design (scope, drift check / fix / router / resync) and CONTEXT.md drift paragraph
- Verify: composer test, integration, phpcs 0
- PR: PR-086
2026-09-27 09:52:22 +08:00
wpdev 07955192d4 chore(2meet-data-optimizer): clean HivePress transients left in wp_options
- `wp tmdo cleanup-hp-transients --options [--dry-run|--confirm] [--batch=<n>]` removes the rows the HivePress AddOn wrote to wp_options (`_transient_wpdo_hp_pm_*` and their timeouts) before AddOn 0.3.0 kept them in the object cache, deleting in batches (default 5000). It works without the AddOn active; the namespace is repeated in core for that.
- Verify: CleanupHpTransientsTest (RED before; only the AddOn's rows go, look-alike names stay); dev36 --dry-run counts 2,314,750 rows in 3.2 s. The --confirm run belongs after the AddOn change is deployed.
- PR: PR-080
2026-09-27 09:06:19 +08:00
wpdev 84f6e32913 perf(2meet-data-optimizer): route sort-only searches from the flat table when every post has a row
- A sort-only search (HivePress _sort, no routed filter) LEFT JOINs the flat table to keep posts without a row; it now drives from the flat table (STRAIGHT_JOIN, INNER JOIN) when every post of the type has a flat row. The router gives a new post of a type with its own groups an empty row (wp_insert_post); coverage is one anti-join per group, cached 15 min and per post-mode change (forget_coverage()).
- A flat-first search drops the GROUP BY posts.ID WordPress adds for any tax query when the flat row is the only join (WooCommerce's product_visibility NOT IN on searches is a subquery); a joining tax query keeps it.
- Verify: live router suite (5 new cases, RED before); dev36 rating__desc page, ids identical: native ~3.3 s, 2.1 routing ~1.5–1.9 s, now ~0.51–0.85 s (main query 440 → 46 ms, COUNT 400 → 160 ms). p50 ≤ 300 ms is still not met: page rendering and HivePress's own get_posts() in pre_get_posts (suppress_filters, ~100 ms) remain.
- PR: PR-079
2026-09-27 08:31:47 +08:00
wpdev ad7acc40b7 chore(2meet-data-optimizer): address the 2.1 audit notes
- The four public REST routes say why they are public (viewable posts only, show_in_rest fields only, per_page cap, one view per IP per post per hour).
- Admin ternary echoes of literal strings are escaped for their context: esc_attr() in attributes, esc_html() for text; the object-cache badge is an if/else and aria-current is always printed ("false" off the current tab).
- Exceptions in the migration engine and the health cron go to TMDO_Logger instead of error_log().
- Verify: audit greps (ternary echoes 23 → 0, error_log outside the logger 0); dev36 renders 8 admin tabs with the change, no PHP notice, one aria-current="page" each.
- PR: PR-078
2026-09-27 01:19:22 +08:00
wpdev f48df1a33f refactor(2meet-data-optimizer): stop registering the wc_product post group
- WooCommerce reads product meta directly and writes stock by SQL, so a flat copy drifted (R-2.1 V1 T4); product meta now stays in native only (plan decision 2). TMDO_Post_Fields registers wp_core, attachment and nav_menu_item. Existing wp_wpdo_post_wc_product tables are left in place and still dropped on uninstall; the orphan restore registers nothing for them while native meta holds the keys; `wp tmdo bridge-restore-native post --group=wc_product --retire` archives them.
- Tests that route products register the former group themselves (tmdo_test_register_wc_product_group(), same name and table); live router Q06 runs on wp_core's _wp_page_template instead of _sku. The post stress tester already skips flat tables that do not exist.
- Verify: PostFieldsRegistrationTest (RED before); dev36: wc_product absent from the registry and not restored as an orphan, a CLI order took stock 452 → 450 in native with no DB error (reverted), drift post 10 groups 0.
- PR: PR-077
2026-09-27 01:11:52 +08:00
wpdev f15473527c fix(2meet-data-optimizer): stop false alarms in doctor, term-comment-diagnose and bridge-status
- doctor: entity flat tables come from the registry (TMDO_CLI::check_entity_tables()) instead of a hard-coded user list naming the never-registered sso group, which failed doctor on every site; it also reports Promotion Guard violations and the recorded drift status per entity (report_guard_and_drift()).
- term-comment-diagnose: filter status comes from each filter's is_enabled() (the garbage filter and misc bucket default to off but were shown enabled); a filter whose add-on is not active shows "not installed".
- bridge-status: flat_rows counts rows holding a value (wp_core keeps an all-NULL row per post: 15306% on dev36) and eav_ids of a post-type group counts only its post types.
- Tests: the WP_CLI stubs record their output in $GLOBALS['_wp_cli_output'].
- Verify: DoctorTest, CliFilterStatusTest, CliBridgeStatusTest (RED before); dev36 doctor exit 1 → 0, wp_core 15306% → 100%.
- PR: PR-076
2026-09-27 01:00:13 +08:00
wpdev 9d1a7c5591 feat(2meet-data-optimizer): throttle shadow_read diffs per key and promote on clean drift checks
- The shadow diff logger records at most 20 rows per entity type + key per hour across all objects, on top of the existing per-object 5-minute window: a key stale on every object (WooCommerce total_sales, written by direct SQL) no longer logs a row per product read.
- The auto-promoter's third condition is now TMDO_Drift_Check::clean_since(): every backfillable group of the entity checked within 2 days (sampled or full) with no drift; no record, no promotion. The shadow diff count no longer gates it (the filter's 4th argument is passed as 0). The Promotion Guard still refuses HivePress / WooCommerce sites.
- Reason classification was dropped (decision 2026-09-27): it needs a schema change and drift-check already reports miss / diff / extra per key.
- Verify: ShadowDiffThrottleTest, AutoPromoterDriftTest (RED before); dev36 simulated user shadow_read: clean_since yes, promotion still refused by the guard, mode unchanged.
- PR: PR-075
2026-09-27 00:48:18 +08:00
wpdev a26500206a feat(2meet-data-optimizer): backfill writes made while the Hook Bus was off
- When an entity goes from disabled to dual_write / shadow_read, or the Hook Bus flag is switched on, a single WP-Cron event (tmdo_drift_resync, one per entity, 60 s later) runs drift-check --fix for the entities that keep native meta, so values written while flat was bypassed land in flat. aeav_only is never touched.
- A full fix instead of the planned post_modified_gmt increment (decision 2026-09-27): meta-only writes such as WooCommerce's direct stock SQL do not bump post_modified. No wpdo_bridge_off_since.
- The resync hook is cleared on deactivate / uninstall (literal name: uninstall.php does not load TMDO_Drift_Check).
- Verify: DriftCheckTest resync cases (RED before); dev36 end to end with real WP-Cron: write while user disabled → miss 1 → bridge-mode user dual_write schedules tmdo_drift_resync [user] → run → drift 0.
- PR: PR-074
2026-09-27 00:41:43 +08:00
wpdev 1b21ee9add fix(2meet-data-optimizer): drift checks only where native meta is authoritative
- In aeav_only flat is the source of truth and native meta is stale by design, so drift there means nothing: drift-check --fix refuses (it would overwrite current flat values with stale native ones), the daily sample skips such entities, and the router keeps routing a drifting group instead of falling back to stale native meta.
- Found before PR-074 on hardening/2.2 (PR-071–073 not released; dev21 still runs aeav_only).
- Verify: DriftCheckTest / PostQueryRouterDriftTest aeav_only cases (RED before); dev36 fix() in a simulated aeav_only refused.
- PR: PR-073b
2026-09-27 00:34:03 +08:00
wpdev 6c782282c7 feat(2meet-data-optimizer): sample drift daily and record the result
- TMDO_Drift_Check::cron_tick() runs on the existing wpdo_daily_health_check event (no new schedule to add or clear): every entity type whose flat tables are written (dual_write or higher) is checked on 500 random entities per group, and the outcome replaces the recorded status, so a group found drifting leaves the router that day.
- Verify: DriftCheckTest cron cases (RED: method / hook missing); dev36 with all four entities in dual_write: one tick over 22 groups in 10 s, 0 drift, status option restored.
- PR: PR-073
2026-09-27 00:28:00 +08:00
wpdev ddefdc16d4 fix(2meet-data-optimizer): keep searches native while a group is known to drift
- The post query router routes no clause of a group whose last drift-check recorded miss / diff / extra; all or nothing, so such a query stays fully native. A group never checked keeps routing (decision 2026-09-27: no slowdown after upgrading; PR-073 samples daily).
- The check sits in route_clause(), which every routed clause, HivePress sort shape and meta_key sort goes through.
- Verify: PostQueryRouterDriftTest (drifting group and drifting sort key RED before); dev36 hp_listing price search in shadow_read: clean → flat, drift → native, unchecked → flat, found 90347 each.
- PR: PR-072
2026-09-27 00:22:47 +08:00
wpdev 3a347269c8 feat(2meet-data-optimizer): repair drifted flat rows from native with drift-check --fix
- TMDO_Drift_Check::fix() deletes flat rows of deleted entities and, for post groups, of posts outside the group's post types; rebuilds every other drifted entity's row from native through the new TMDO_Entity_Migration_Engine::migrate_entity() (keys without native rows become NULL, PR-066); keeps a row whose entity has no native value for the group but clears its columns (no NULL-only inserts, no deleted rows the router may join on); invalidates the row cache of each touched entity; then checks again. Native meta is never written.
- `wp tmdo drift-check <entity> --fix` (not combinable with --sample).
- Verify: DriftCheckTest fix cases (RED: method missing); dev36 --fix: post 42 rows rebuilt, 1 stray row (post 312, hp_booking in hp_draft_flags) deleted, all four entities 0 drift after, checker miss/extra 0.
- PR: PR-071
2026-09-27 00:07:33 +08:00
wpdev 02045c6f07 feat(2meet-data-optimizer): report flat-vs-native drift with wp tmdo drift-check
- New TMDO_Drift_Check compares every backfillable group of post / user / term / comment with native meta by value (first native row → safe unserialize → Type_Caster::to_db): miss / diff / extra per key, set-based SQL with BINARY exact matches, PHP comparison only for the remaining candidates. Serialized-vs-JSON and 50 vs 50.000000 are no longer drift; over-long text stored as NULL is not a miss; extra covers rows of other post types. Outcome per group goes to the wpdo_drift_status option; nothing else is written.
- `wp tmdo drift-check <entity> [--group] [--sample=<n>] [--format]`. A new class instead of extending the post-only shadow verifier, which queries row by row and writes shadow_diffs.
- Verify: DriftCheckTest; live suite drift; dev36 post miss=533 diff=2 extra=1 in 23 s (checker's 45 json false diffs gone), user/term/comment 0.
- PR: PR-070
2026-09-26 23:56:23 +08:00
wpdev 382786cb9d feat(2meet-data-optimizer): set an entity bridge mode from the CLI under the Promotion Guard
- New `wp tmdo bridge-mode <entity> <mode>` goes through TMDO_Mode_Manager::set(): unsafe jumps and aeav_only promotions refused by the Promotion Guard exit with the reason and leave the mode unchanged. Named apart from the Feature Flags `mode-set`.
- member-sso-sync and member-force-logout warn that they are deprecated: the user sso group they work on is no longer registered.
- Verify: CliBridgeModeTest (RED: method missing); dev36 bridge-mode user aeav_only refused (unsafe jump), term dual_write → shadow_read → aeav_only refused by the HivePress guard → back to disabled, wpdo_bridge_modes identical to before.
- PR: PR-069
2026-09-26 23:36:55 +08:00
wpdev 2022cf1d08 feat(2meet-data-optimizer): backfill every registered user group with member-backfill --all
- member-backfill / member-audit take their groups from the registry (TMDO_Entity_Migration_Engine::backfillable_groups(), restored orphans excluded) instead of a hard-coded list that still named the never-registered `sso`; `--all` backfills every user group (R-2.1 had to use wp eval). TMDO_Post_Migration::backfillable_groups() now delegates to it.
- member-cutover checks the Mode_Manager result with is_wp_error(): a guarded or invalid transition used to crash with a fatal (WP_Error used as an array).
- Verify: CliMemberBackfillTest (--all and the orphan filter RED before; the --group case cannot fail under the non-throwing WP_CLI stub); dev36 `member-backfill --all --reset` 9 groups, 0 errors, checker user miss/diff/extra 0; `member-cutover --confirm` exits 1 with the Guard message, mode unchanged.
- PR: PR-068
2026-09-26 23:30:28 +08:00
wpdev 7807eae957 refactor(2meet-data-optimizer): list post groups for backfill from the registry
- Admin "backfill all" and `wp tmdo post-migrate-group` take any registered post group via TMDO_Post_Migration::backfillable_groups() instead of a hard-coded list of 7 (dev36 registers 11; the 4 HP add-on groups were never backfilled). Restored orphan groups are excluded and rejected — their values live only in the flat table.
- Groups with json fields (attachment, nav_menu_item) go row by row through the migration engine instead of leaving the json columns NULL in the bulk pivot.
- Verify: PostBackfillJsonTest (3 RED on hardening/2.2); dev36 post-migrate-group attachment / nav_menu_item / hp_draft_flags: json columns semantically equal native (24/24, 21/21), checker unchanged.
- PR: PR-067
2026-09-26 22:54:27 +08:00
wpdev 13ea341ef8 fix(2meet-data-optimizer): do not invent default rows when migrating an entity group
- migrate_group() REPLACEs the whole flat row; columns without a native row fell back to their DDL default (session_count / account_flags DEFAULT 0), so every backfilled user got invented zeros. Keys with no native row are now written as NULL; required NOT NULL columns keep their default.
- Covers the cron batch path (migrate_group_batch) and clears stale flat values on re-run.
- Verify: EntityMigrationEngineDefaultsTest (3 RED on main); dev36 user/activity backfill: checker extra 123 → 0 for session_count and account_flags, miss/diff unchanged.
- PR: PR-066
2026-09-26 22:46:01 +08:00
wpdev 957f5c4ab0 Merge pull request 'hotfix 1.1.1: 2meet-data-optimizer — keep private fields out of the public listings REST endpoints' (#5) from hotfix/1.1.1 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 19s
Tests / PHP Lint (push) Successful in 50s
Tests / Unit Tests (push) Successful in 1m6s
Tests / Integration Tests (push) Successful in 1m42s
Tests / PHPCS (push) Successful in 41s
Tests / PHPStan (push) Successful in 1m4s
2026-09-26 22:25:23 +08:00
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
113 changed files with 9787 additions and 701 deletions
+7 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer * Plugin Name: 2meet Data Optimizer
* Plugin URI: https://2meet.io/2meet-data-optimizer * Plugin URI: https://2meet.io/2meet-data-optimizer
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。 * Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
* Version: 1.0.2 * Version: 1.4.3
* Requires at least: 6.0 * Requires at least: 6.0
* Tested up to: 6.9.4 * Tested up to: 6.9.4
* Requires PHP: 8.1 * Requires PHP: 8.1
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
} }
// ── Constants ────────────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────────────
define( 'TMDO_VERSION', '1.0.2' ); define( 'TMDO_VERSION', '1.4.3' );
define( 'TMDO_DB_VERSION', '2.1.0' ); define( 'TMDO_DB_VERSION', '2.1.0' );
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) ); define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
@@ -234,6 +234,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// ── v2.0.0 Engine + Adapters + Modules ─────────────────────────────────── // ── 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-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.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-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-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'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -260,12 +261,14 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) ); 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( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) ); add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php'; require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php'; require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php'; require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
@@ -292,6 +295,8 @@ if ( is_admin() ) {
TMDO_Deprecated_Addons_Notice::register(); TMDO_Deprecated_Addons_Notice::register();
require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php'; require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php';
TMDO_Missing_Addons_Notice::register(); TMDO_Missing_Addons_Notice::register();
require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php';
TMDO_Promotion_Guard_Notice::register();
} }
// ── WP-CLI ───────────────────────────────────────────────────────────────── // ── WP-CLI ─────────────────────────────────────────────────────────────────
+343
View File
@@ -7,6 +7,341 @@ Versioning follows [Semantic Versioning](https://semver.org/).
--- ---
## [1.4.2] — 2026-09-28 — 手冊已知問題修正(hardening 2.4.2)
**性質**:修正。無 schema 版本變更;archive 表新增索引(見「升級後」)。來源:`2meet-do-manual.md`
附錄 F(F-2、F-4〜F-8、F-11)。
### Fixed
- **設定頁會說明模式為何沒有變更**:完整表單與 AJAX 區塊儲存都經 `TMDO_Mode_Manager::set()`,
過去忽略它回傳的 `WP_Error`:不安全的跳階或 Promotion Guard 拒絕時,頁面仍顯示「已儲存」、
模式卻沒變。現在完整表單在重新導向後顯示拒絕原因(每位使用者一次),AJAX 儲存以錯誤訊息回傳。(PR-135,F-2)
- **archive 不再重複歸檔**:`wpdo_archive_sweep` 略過已在 archive 表的 meta(依 `original_meta_id`),
過去同一筆 meta 每次 sweep 都再寫一列(dev20:300 列只有 150 筆不同)。archive 表新增
`idx_original_meta` 索引;`wp tmdo install` 會補上索引並刪除重複列(保留最早一列)。(PR-136,F-6)
- **entity 健康面板看得到執行中的 backfill**:批次以 `(entity, group)` 參數排程,面板過去只查
`(entity)`,永遠看不到、一直提示可開始。(PR-137,F-11)
- **停用與解除安裝清掉所有排程**:改用 `wp_unschedule_hook()`,連帶參數排程的事件
(`tmdo_drift_resync`、backfill 批次)也清除;修正錯誤的 `wpdo_user_stress_test_batch` 名稱,
補上 `wpdo_migration_tick`、`wpdo_entity_backfill_batch`、`wpdo_auto_promote_check` 與各壓測批次。
解除安裝另外刪除 `tmdo_*` option 及 `tmdo_*` transient。(PR-138,F-4、F-5)
- 解除安裝被拒絕時的提示改為指向 `wp tmdo bridge-restore-native`(過去說沒有寫回工具);post
壓測工具不再使用已退役的 `wc_product` 表。(PR-139,F-7、F-8)
### 升級後
- 每個站台執行一次 `wp tmdo install`:為 archive 表加上 `idx_original_meta` 並刪除重複的 archive
列(刪除重複只在 CLI 執行,web 啟用外掛時不做)。表很大時請在離峰時段執行。
- **未執行前 archive sweep 會暫停**(沒有索引時 NOT EXISTS 會對每筆候選掃整張 archive 表)。
sweep 只複製、不刪 postmeta,暫停不會遺失資料。
- 解除安裝的 option 清理保留 LatePoint AddOn 的 `tmdo_latepoint_db_version`;設定頁的 AJAX 錯誤訊息
不再 2.5 秒後自動消失。(PR-141,驗收修正)
### 已知、未修改
- 已歸檔的 meta 以 `meta_id` 判斷:文章還原、修改後再次丟到回收桶,archive 保留的是第一次的值。
- post 壓測工具預設仍是 `product`,但 product 已沒有 flat 表,量測會回報 `flat_table_missing`。
---
## [1.4.1] — 2026-09-28 — 2.4 驗收修正
**性質**:修正。無 schema 變更。來源:2.4 全面驗收的 security review 與 correctness review。
### Fixed
- **meta_key 的排序不再被清掉**:查詢帶 `meta_key`,且 orderby 在多個排序(陣列或
`'meta_value date'`)中仍有 `meta_value`、`meta_value_num` 或該 key 名稱時,1.4.0 會把
meta_key 當 EXISTS 路由並清空,WordPress 因此丟掉價格排序。現在只在 orderby 完全沒有引用它時
才路由;`meta_compare_key` 不是 `=`(例如 LIKE 會比對到其他 key)時維持原生;排序形狀的
group 只有在 orderby 沒有引用它、`meta_value(_num)` 或它的 key 時才當成未使用而略過(沒有
meta_key 時它是 WordPress 的主要 clause)。(PR-130)
- **ORDER BY 只改寫單獨的 `posts.ID`**:flat-first 與 copy route 共用 `ties_on_post_id()`;
外掛加上的 `FIELD(wp_posts.ID, …)`、`(wp_posts.ID IN (…))` 等運算式保持原樣。1.4.0 會在
運算式內插入方向,SQL 語法錯誤、頁面沒有結果。正則失敗(PCRE 回溯或 JIT 上限)時回傳原
SQL,不再變成空字串。(PR-131)
- 呼叫端以 `new WP_Query( array( … ) )` 預設的 router 內部 query var(`wpdo_post_clauses`、
`wpdo_post_sort`、`wpdo_post_copy_route`、`wpdo_post_count_sql`)一律清除;copy route 的
排序方向改用白名單。無法從網址觸發。(PR-132)
- 新文章建立的空 flat 列一併寫入 `post_status` / `post_date` 副本(`wp_insert_post` 在
`transition_post_status` 之後才執行,原本留下 NULL,會讓整個 group 關閉 copy route);
`wp tmdo post-status-sync` 完成後遞增路由計數版本(dry run 不遞增)。(PR-133)
### 已知、未修改
- 1.4.0 的 flat-first 排序同值時依 ID 與排序同方向(原生為 ID DESC),集合與總數相同(已接受)。
- 若日後有 group 註冊名為 `post_status` / `post_date` 的欄位,遷移引擎寫副本時 `$data` 與
`$formats` 會錯位;目前沒有這樣的 group。
---
## [1.4.0] — 2026-09-28 — 百萬級 listing 的路由排序與日期查詢(hardening 2.4)
**性質**:效能 + 修正。無 schema 變更;post status/date 副本的索引改為三欄(見「升級後」)。
搭配 HivePress AddOn 0.5.0。來源:dev36 第 3 階壓測(130 萬 listing、121 萬 product、6.2 萬 vendor)。
### Changed
- **flat-first 排序讀 flat 的 post_id 作 tie-break**:`posts.ID` 的 tie-break(ours 與外掛追加的)
改讀相等的 `flat.post_id`,方向與排序一致且只出現一次;group 宣告的索引可以 adapter 的
entity id 欄結尾,`(欄, post_id)` 索引可直接依序讀取、不需 filesort(主查詢 850 ms → 2 ms)。
同值的順序改為依 ID 與排序同方向(原生為 ID DESC);集合、總數不變,翻頁穩定。(PR-118)
- **沒有 value 的 meta 條件以 EXISTS 路由**:clause 沒有 `value` 時(HivePress 的精選 listing
查詢)視同 EXISTS;只帶 `meta_key`、沒有 `meta_value` 也不以 meta_value 排序的查詢亦同;
沒有被 orderby 引用的排序形狀 group 直接移除。HivePress 每個搜尋頁都會跑的精選 listing
查詢(`orderby rand`)改走扁平表:2.26 s → 0.2 s。(PR-122)
- **status/date copy route 在 LIMIT 停下**:只有扁平表一個 JOIN 時去掉 WordPress 的
`GROUP BY posts.ID`,`posts.ID` tie-break 改讀 `flat.post_id`(各自保留方向、重複合併);副本索引改為
`idx_post_status_date_id (post_status, post_date, post_id)`,取代兩欄的 `idx_post_status_date`。
dev36 價格區間(130 萬中 23.5 萬筆符合)主查詢 1.7 s → 3 ms,頁面 2.7 s → 0.34 s。(PR-128)
### Fixed
- 遷移引擎(backfill、`drift-check --fix`)以 REPLACE 重建 post 列時保留 `post_status` /
`post_date` 副本;原本重建後為 NULL,一筆 NULL 就讓整個 group 關閉 copy route。(PR-127)
- 遷移引擎重建 post 列後遞增路由計數版本(每次呼叫/每批一次,dry run 不遞增),
修正後的 found rows 不再沿用舊的快取計數。(PR-129)
### Dev
- live 測試工具(`mut.php`、`qbench.php`)不再清空物件快取(Redis Object Cache 以伺服器端腳本
掃描,會卡住共用 Redis 5–20 s)。(PR-123)
### 升級後
- 只需替換索引:對 `tmdo_post_status_columns` 列出的每個 group 表執行
`ALTER TABLE <表> ADD INDEX idx_post_status_date_id (post_status, post_date, post_id), DROP INDEX idx_post_status_date`
(空表瞬間完成,dev36 130 萬列約 5 s)。未替換前 copy route 照常運作,只是維持 1.3.0 的速度。
- **不要為了換索引執行 `wp tmdo post-status-sync`**:它除了替換索引,還會替**所有**尚未有副本的
post group 加上 `post_status` / `post_date` 欄位並逐列回填(大表 ALTER + 全表 UPDATE;dev36
130 萬列約 75 s,上千萬列的站更久)。只有要讓更多 group 啟用 copy route 時才執行,並先在
離峰時段以 `--dry-run` 確認列數。
- 效能數字(dev36 前台,routed):價格區間 0.34 s、價格排序 0.37 s、時薪排序 0.35 s、
評分排序 0.51 s、關鍵字 + 價格 0.59 s(原生 6–43 s)。
---
## [1.3.0] — 2026-09-27 — 大表索引、路由計數與稽核修正(hardening 2.3)
**性質**:效能 + 稽核修正。無 schema 變更(宣告的複合索引以 ALTER ADD INDEX 就地加上)。
搭配 HivePress AddOn 0.4.0、WooCommerce AddOn 0.3.0。來源:dev36 規模壓測(90 萬 listing)
與系列稽核(2026-09-27)。
### Changed
- **group 宣告的複合索引只在 WP-CLI 或 WP-Cron 加上**:網頁請求(包括 auth_redirect 之前的
`/wp-admin/` 與 `admin-post.php`)改排單次事件 `tmdo_ensure_group_indexes`;以 MySQL
`GET_LOCK` 保證同一時間只有一個程序執行 ALTER;每個 ALTER 都成功才記錄已套用,失敗記
`schema.index_failed` 並保留在 `doctor` 的 `[INDEX]` 待辦;新表在 CREATE TABLE 時就帶上
索引。filter:`tmdo_schema_ddl_allowed`。dev36 90 萬列加兩個索引約 4.7 s。(PR-090a)
- **路由搜尋的 found rows**:帶 `post__not_in` 的查詢(HivePress 每頁送一組隨機排除清單)
改為「不含排除條件的 COUNT(快取)」減去「排除清單中符合條件的篇數(主鍵查詢)」,結果
精確,快取不再被打散。(PR-091)快取 key 另外去掉 wpdb 替 `%` 產生的 placeholder escape
(每個請求不同,關鍵字搜尋原本永遠不命中,也就是 2.2 驗收「關鍵字 + 篩選 routed 較慢」的
原因)(PR-116);計數改跟隨自有的 `tmdo_posts` last_changed(文章儲存/刪除、經 Hook Bus
的扁平寫入)與 terms 的 last_changed,不再跟隨 posts 的 last_changed——任何 post meta 寫入
都會推動它,dev36 的 Blocksy 佈景在爬蟲瀏覽每篇 listing 時都寫一筆 meta,快取幾秒就失效
(PR-117)。dev36 關鍵字 + 價格 routed 9.8 s → 首次後約 5.5 s(原生 5.0–6.2 s,剩餘為
WordPress 核心的 LIKE 全表掃描)。
- `POST /wpdo/v1/listings/{id}/view` 被限流擋下時,`wpdo_rl_stats` 改為 1/10 抽樣寫入
(每次 +10),匿名請求不再 1:1 放大成 option 寫入。(PR-094)
- `TMDO_Logger::info()` 只在 `WP_DEBUG_LOG` 時寫 error_log(filter `tmdo_log_info`);
`notice()` 一律寫入,模式切換(`bridge_mode_changed`)與快照刪除改用它,保留操作紀錄;
`debug()`、`warning()`、`error()` 不變。(PR-095、PR-113)
- 公開 listings REST 一律不輸出名稱像 email、phone、address、IP 的欄位,即使宣告
`show_in_rest`。(PR-096)
- **既有扁平表的 schema 升級只在 WP-CLI 或 WP-Cron 執行**:網頁請求遇到定義變更時記入
`tmdo_schema_deferred` 並排 `tmdo_ensure_group_indexes`;升級前該 group 的 key 走原生
(Hook Bus 不攔、router 不路由),升級後排 `tmdo_drift_resync` 補回期間跳過的扁平寫入。
新表照常立即建立;aeav_only 仍立即升級(扁平表是唯一副本);`doctor` 顯示 `[SCHEMA]`。(PR-107)
驗收審查後補強:既有表的升級與索引共用同一把 `GET_LOCK`(匿名 `/wp-cron.php` 也會通過
`ddl_allowed()`),取得鎖後重新比對 hash;延後的 group 要等 drift fix 完成才改回扁平表;
寫入扁平表的 entity 新建 group 表時同樣先走原生,由 drift fix 從原生填滿(新宣告的
HivePress 屬性不會把搜尋導到空表);定義還原時解除延後;cron 事件本身也會升級延後的表
(`ALTERNATE_WP_CRON`);dbDelta 之後每個欄位都存在才記錄定義(否則 `schema.upgrade_failed`
並重試);延後期間 `direct_read()` 回傳原生值。(PR-111、PR-114)
- 讀寫分離(HyperDB、LudicrousDB)時,取索引鎖前先 `send_reads_to_masters()`,
`GET_LOCK` 才會在 primary 上。(PR-108)
- Cold 快取的「清除」改為遞增版本號(`wpdo_cold_cache_version`),不再呼叫
`wp_cache_flush_group()`(Redis Object Cache 以伺服器端腳本掃描整個 group,大 group 會卡住
Redis);舊 key 依 TTL 過期。(PR-110)
### Fixed
- 後台主頁與設定精靈第 4 步的表單改在 `load-{page}`(輸出前)處理,`wp_safe_redirect()`
可以正常生效;精靈快照失敗改以 `snap_failed=1` 導回。(PR-097)
- `uninstall.php` 的全域變數加上 `$wpdo_` 前綴(WordPress.org Plugin Check)。(PR-098)
- 扁平表已被刪除的 group 不再留在 `tmdo_post_status_columns`:每次文章狀態變更都 UPDATE
不存在的表並印出 DB error。(PR-106)
### Security(已驗證,無程式變更)
- 快照目錄在 nginx 下的外洩風險(稽核 Medium):快照檔名含 64-bit 隨機值、遷移備份檔名含
12 字元隨機 job id,目錄有 index.php 擋列表;`doctor` 的 HTTP 探測與 nginx 後台提示已存在。
dev31 探測全為 403。不採用「目錄名加隨機後綴」:各站 nginx deny 規則比對 `wpdo-backups/`,
改名反而讓新快照脫離保護。(PR-093)
### 升級後
- 部署後第一個 WP-CLI 指令或 WP-Cron 會為 HivePress AddOn 0.4.0 宣告的索引執行 ALTER
(90 萬列約 5 s);`DISABLE_WP_CRON` 且沒有系統 cron 的站,執行一次 `wp tmdo doctor` 即可。
- CLI 輸出不再夾帶 `[WPDO][INFO]`;要看請定義 `WP_DEBUG_LOG` 或加上 `tmdo_log_info` filter。
- 已知限制:cold 快取版本遞增的瞬間,在遞增前就開始的長請求寫入後只會清掉舊版 key,新版 key
最多保留舊值到 TTL(預設 1 小時);cold module 預設 idle。
---
## [1.2.0] — 2026-09-27 — 漂移偵測、範圍收斂、CLI 與診斷(hardening 2.2)
**性質**:功能 + 資料正確性修正。無 schema 變更。延續 ADR-004 的搜尋索引模式;搭配
HivePress AddOn 0.3.0、WooCommerce AddOn 0.2.0。
### Added
- **`wp tmdo drift-check <entity> [--group] [--sample=<n>] [--format]`**(`TMDO_Drift_Check`):
逐 key 比對扁平表與原生 meta 的值(第一筆原生 row → 安全 unserialize →
`Type_Caster::to_db`),回報 miss / diff / extra。序列化 vs JSON、`50` vs `50.000000`
不算漂移;超過 255 字元存 NULL 的 text 不算 miss。結果寫入 `wpdo_drift_status`。(PR-070)
- **`drift-check --fix`**:刪除已刪實體與非本 group post type 的扁平列,其餘漂移列經
`TMDO_Entity_Migration_Engine::migrate_entity()` 從原生重建,清除列快取後再檢查一次。
不寫原生;aeav_only(扁平才是真實資料)一律拒絕。(PR-071、PR-073b)
- **每日抽樣**:`wpdo_daily_health_check` 對 dual_write 以上、非 aeav_only 的 entity 每個
group 抽 500 筆,結果取代既有紀錄。(PR-073)
- **Hook Bus 關閉期間的寫入補回**:entity 從 disabled 進入 dual_write / shadow_read,或
Hook Bus flag 由關轉開時,60 秒後以單次 WP-Cron `tmdo_drift_resync` 執行全量
`drift-check --fix`(不做 `post_modified_gmt` 增量:WooCommerce 直接 SQL 改庫存不會更新
post_modified)。停用 / 解除安裝時清除排程。(PR-074)
- **`wp tmdo bridge-mode <entity> <mode>`**:經 `TMDO_Mode_Manager::set()`,受
Promotion Guard 約束;被拒時 exit 1、模式不變。(PR-069)
- **`wp tmdo member-backfill --all`**:回填所有已註冊的 user group。(PR-068)
- **`wp tmdo cleanup-hp-transients --options [--dry-run|--confirm] [--batch=<n>]`**:
刪除 HivePress AddOn 0.3.0 之前寫進 wp_options 的 `_transient_wpdo_hp_pm_*`(含 timeout),
分批刪除(預設 5000),AddOn 未啟用也可執行。dev36 有 2,314,750 列。(PR-080)
### Changed
- **Post query router 避開漂移中的 group**:上次 drift-check 有 miss / diff / extra 的
group 不路由,整個查詢維持原生(全有或全無);從未檢查的 group 照常路由;aeav_only
不受影響。(PR-072、PR-073b)
- **純排序搜尋 flat-first**:沒有路由篩選的 HivePress `_sort` 搜尋,在該 post type 每篇都有
扁平列時改由扁平表驅動(`STRAIGHT_JOIN` + INNER JOIN);Router 在 `wp_insert_post`
替新文章建空列,覆蓋率以 anti-join 檢查並快取 15 分鐘(隨 post 模式變更失效)。
flat-first 且扁平表是唯一 JOIN 時拿掉 WordPress 為 tax query 加的 `GROUP BY posts.ID`。
dev36 `rating__desc`:原生約 3.3 s、1.1.0 約 1.5–1.9 s、現在約 0.51–0.85 s。(PR-079)
- **Auto-promoter** 的第三個條件改為 `TMDO_Drift_Check::clean_since()`:2 天內每個 group 都
檢查過(抽樣或全量)且沒有漂移;沒有紀錄就不升級。(PR-075)
- **shadow_read diff 節流**:每個 entity type + key 每小時最多記 20 列(疊加既有的
每物件 5 分鐘視窗)。(PR-075)
- **回填 group 改讀 registry**:admin「全部回填」、`post-migrate-group`、`member-backfill`、
`member-audit` 取所有已註冊 group(排除已還原的孤兒 group),不再是寫死清單;含 json
欄位的 group(attachment、nav_menu_item)逐列經 migration engine。(PR-067、PR-068)
- **不再註冊 `post/wc_product`**:WooCommerce 直接讀 product meta、以 SQL 寫庫存,扁平副本
必然漂移(ADR-004 決策 2)。既有的 `wp_wpdo_post_wc_product` 表保留,以
`wp tmdo bridge-restore-native post --group=wc_product --retire` 封存;解除安裝仍會刪除。(PR-077)
- `member-sso-sync`、`member-force-logout` 標示為 deprecated(`sso` group 已不註冊)。(PR-069)
### Fixed
- Entity 回填替沒有原生 row 的 key 寫入 DDL 預設值(`session_count` / `account_flags` = 0),
每個 user 都被捏造出 0;現在寫 NULL,required NOT NULL 欄位保留預設值。(PR-066)
- `member-cutover` 在轉換被拒時 fatal(把 `WP_Error` 當陣列)。(PR-068)
- `doctor` 依 registry 檢查 entity 表(不再因從未註冊的 `sso` 表而每站失敗),並顯示
Promotion Guard 違規與 drift 狀態;`term-comment-diagnose` 依各 filter 的 `is_enabled()`
顯示狀態;`bridge-status` 只計有值的扁平列、eav_ids 限定 group 的 post type
(dev36 wp_core 15306% → 100%)。(PR-076)
- 2.1 稽核:公開 REST route 註明公開理由;admin 23 處三元 echo 依情境 `esc_attr` /
`esc_html`;migration engine 與 health cron 的例外改記 `TMDO_Logger`。(PR-078)
### Docs
- ADR-004「設計(Release 2.2)」、`CONTEXT.md` Entity Bridge 的漂移段落。(PR-086)
### 升級後
- HivePress 站:部署 AddOn 0.3.0 後執行 `wp tmdo cleanup-hp-transients --options --confirm`。
- 曾註冊 wc_product 的站:`wp tmdo bridge-restore-native post --group=wc_product --retire`。
- 已進入 dual_write 以上的站:`wp tmdo drift-check <entity>` 建立第一筆紀錄(未檢查的
group 照常路由)。
---
## [1.1.1] — 2026-09-26 — Security:公開 REST 不再回傳私有欄位
**性質**:安全修正(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 設定選項保護 ## [1.0.2] — 2026-08-15 — NinjaFirewall 相容性:WAF 設定選項保護
**性質**:防禦性修正 + 文件。無 schema 變更,無破壞性變更。 **性質**:防禦性修正 + 文件。無 schema 變更,無破壞性變更。
@@ -266,3 +601,11 @@ Versioning follows [Semantic Versioning](https://semver.org/).
- PHPCS 套件 + 0/0 達成 - PHPCS 套件 + 0/0 達成
- 打包腳本 10 終檢驗收 - 打包腳本 10 終檢驗收
- WPDO_ → TMDO_ migration CLI - WPDO_ → TMDO_ migration CLI
## [1.4.3] — 2026-10-02 — 重新打包
### Changed
- 重新打包(無功能變更):與 WP-MVB v8 樞紐站(dev39)一同驗收後發版;程式碼與前一版相同。
---
+30
View File
@@ -135,6 +135,36 @@ 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 The Entity Bridge and the Zone system coexist; the Sync Bridge's
`is_owned_by_entity_bridge()` guard prevents duplicate writes. `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.
**Drift** (v1.2.0). `TMDO_Drift_Check` compares flat values with native meta per key
(miss / diff / extra) and records the outcome per group in `wpdo_drift_status`;
`--fix` rebuilds flat rows from native. The router routes no clause of a group recorded
as drifting (never checked → routes). A daily sample, and a full fix 60 s after an
entity leaves `disabled` or the Hook Bus is switched back on (`tmdo_drift_resync`), keep
the record current. None of this applies in `aeav_only`, where flat is the source of truth.
--- ---
## Migration Phase (Strategy pattern) ## Migration Phase (Strategy pattern)
+5 -2
View File
@@ -45,7 +45,7 @@
} }
/** /**
* Show a transient status message, then fade it out after 2.5s. * Show a status message; success fades out after 2.5s, errors stay.
* *
* @param {HTMLElement} statusEl * @param {HTMLElement} statusEl
* @param {string} message * @param {string} message
@@ -56,6 +56,9 @@
statusEl.style.color = isError ? 'var(--color-error, #b03d3d)' : 'var(--color-success, #5a8c5a)'; statusEl.style.color = isError ? 'var(--color-error, #b03d3d)' : 'var(--color-success, #5a8c5a)';
statusEl.classList.add( 'visible' ); statusEl.classList.add( 'visible' );
clearTimeout( statusEl._wpdo_timer ); clearTimeout( statusEl._wpdo_timer );
if ( isError ) {
return;
}
statusEl._wpdo_timer = setTimeout( () => { statusEl._wpdo_timer = setTimeout( () => {
statusEl.classList.remove( 'visible' ); statusEl.classList.remove( 'visible' );
}, 2500 ); }, 2500 );
@@ -92,7 +95,7 @@
if ( data.success ) { if ( data.success ) {
showStatus( statusEl, i18n.saved, false ); showStatus( statusEl, i18n.saved, false );
} else { } else {
showStatus( statusEl, i18n.error, true ); showStatus( statusEl, ( data.data && data.data.message ) || i18n.error, true );
} }
} ) } )
.catch( () => { .catch( () => {
+112 -38
View File
@@ -89,13 +89,16 @@ class TMDO_Admin {
* Register the admin menu page under Tools. * Register the admin menu page under Tools.
*/ */
public static function register_menu(): void { public static function register_menu(): void {
add_management_page( $hook_suffix = add_management_page(
__( 'WP Data Optimizer', '2meet-data-optimizer' ), __( 'WP Data Optimizer', '2meet-data-optimizer' ),
__( 'WP Data Optimizer', '2meet-data-optimizer' ), __( 'WP Data Optimizer', '2meet-data-optimizer' ),
'manage_options', 'manage_options',
self::MENU_SLUG, self::MENU_SLUG,
array( __CLASS__, 'render_page' ) array( __CLASS__, 'render_page' )
); );
if ( $hook_suffix ) {
add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_post' ) );
}
} }
/** /**
@@ -326,9 +329,10 @@ class TMDO_Admin {
} }
/** /**
* Render the admin page. * Handle the page's form posts on load-{page}, before any output, so their
* redirects work (inside render_page() the admin header was already sent).
*/ */
public static function render_page(): void { public static function handle_post(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) { if ( ! TMDO_Capability::current_user_can_admin() ) {
return; return;
} }
@@ -394,17 +398,7 @@ class TMDO_Admin {
// v2.5.4: Entity Bridge settings (v2.11.0: post added). // v2.5.4: Entity Bridge settings (v2.11.0: post added).
update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false ); update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false );
if ( class_exists( 'TMDO_Mode_Manager' ) ) { self::remember_bridge_mode_refusals( self::apply_bridge_modes( self::posted_bridge_modes() ) );
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
$new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
: '';
if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) {
TMDO_Mode_Manager::set( $entity_type, $new_mode );
}
}
TMDO_Mode_Manager::reset_cache();
}
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) { if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
TMDO_Hook_Bus_Bridge::reset_cache(); TMDO_Hook_Bus_Bridge::reset_cache();
} }
@@ -556,11 +550,11 @@ class TMDO_Admin {
exit; exit;
} }
// v2.10.0: Post Migration Wizard — backfill all 7 groups. // v2.10.0: Post Migration Wizard — backfill every registered post group.
if ( isset( $_POST['wpdo_post_backfill_all'] ) && check_admin_referer( 'wpdo_post_backfill_all' ) && class_exists( 'TMDO_Post_Migration' ) ) { if ( isset( $_POST['wpdo_post_backfill_all'] ) && check_admin_referer( 'wpdo_post_backfill_all' ) && class_exists( 'TMDO_Post_Migration' ) ) {
$total_migrated = 0; $total_migrated = 0;
$errors = array(); $errors = array();
foreach ( array( 'wp_core', 'attachment', 'wc_product', 'hp_listing_core', 'hp_request_core', 'hp_vendor_core', 'nav_menu_item' ) as $group ) { foreach ( TMDO_Post_Migration::backfillable_groups() as $group ) {
try { try {
$result = TMDO_Post_Migration::backfill_group( $group ); $result = TMDO_Post_Migration::backfill_group( $group );
$total_migrated += (int) $result['migrated']; $total_migrated += (int) $result['migrated'];
@@ -752,6 +746,15 @@ class TMDO_Admin {
); );
exit; exit;
} }
}
/**
* Render the admin page.
*/
public static function render_page(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
$tabs = array( $tabs = array(
'dashboard' => __( '儀表板', '2meet-data-optimizer' ), 'dashboard' => __( '儀表板', '2meet-data-optimizer' ),
@@ -774,7 +777,7 @@ class TMDO_Admin {
'hivepress' => __( 'HivePress 整合', '2meet-data-optimizer' ), 'hivepress' => __( 'HivePress 整合', '2meet-data-optimizer' ),
); );
$tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) ); $tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- tab navigation, no state change.
// v2.11.8: legacy tabs removed — redirect bookmarks to current main maintenance entry. // v2.11.8: legacy tabs removed — redirect bookmarks to current main maintenance entry.
if ( in_array( $tab, array( 'sop', 'migration' ), true ) ) { if ( in_array( $tab, array( 'sop', 'migration' ), true ) ) {
$tab = 'entity-bridge'; $tab = 'entity-bridge';
@@ -846,8 +849,8 @@ class TMDO_Admin {
<span class="wpdo-tab-group-label" aria-hidden="true"><?php echo esc_html( $group['label'] ); ?></span> <span class="wpdo-tab-group-label" aria-hidden="true"><?php echo esc_html( $group['label'] ); ?></span>
<?php foreach ( $group_tabs as $slug ) : ?> <?php foreach ( $group_tabs as $slug ) : ?>
<a href="<?php echo esc_url( add_query_arg( 'tab', $slug, admin_url( 'tools.php?page=' . self::MENU_SLUG ) ) ); ?>" <a href="<?php echo esc_url( add_query_arg( 'tab', $slug, admin_url( 'tools.php?page=' . self::MENU_SLUG ) ) ); ?>"
class="nav-tab <?php echo $tab === $slug ? 'nav-tab-active' : ''; ?>" class="nav-tab <?php echo esc_attr( $tab === $slug ? 'nav-tab-active' : '' ); ?>"
<?php echo $tab === $slug ? 'aria-current="page"' : ''; ?>> aria-current="<?php echo esc_attr( $tab === $slug ? 'page' : 'false' ); ?>">
<?php echo esc_html( $tabs[ $slug ] ); ?> <?php echo esc_html( $tabs[ $slug ] ); ?>
<?php if ( isset( $needs_dot[ $slug ] ) ) : ?> <?php if ( isset( $needs_dot[ $slug ] ) ) : ?>
<span class="wpdo-tab-dot" title=" <span class="wpdo-tab-dot" title="
@@ -1099,7 +1102,7 @@ class TMDO_Admin {
<tbody> <tbody>
<tr> <tr>
<th><?php esc_html_e( '資料庫引擎', '2meet-data-optimizer' ); ?></th> <th><?php esc_html_e( '資料庫引擎', '2meet-data-optimizer' ); ?></th>
<td><code><?php echo TMDO_IS_MYSQL ? 'MySQL' : 'SQLite'; ?></code></td> <td><code><?php echo esc_html( TMDO_IS_MYSQL ? 'MySQL' : 'SQLite' ); ?></code></td>
</tr> </tr>
<tr> <tr>
<th><?php esc_html_e( '外掛版本', '2meet-data-optimizer' ); ?></th> <th><?php esc_html_e( '外掛版本', '2meet-data-optimizer' ); ?></th>
@@ -1116,7 +1119,11 @@ class TMDO_Admin {
<tr> <tr>
<th><?php esc_html_e( 'Object Cache', '2meet-data-optimizer' ); ?></th> <th><?php esc_html_e( 'Object Cache', '2meet-data-optimizer' ); ?></th>
<td> <td>
<?php echo wp_using_ext_object_cache() ? '<span class="wpdo-badge wpdo-badge-ok">External</span>' : '<span class="wpdo-badge">Built-in</span>'; ?> <?php if ( wp_using_ext_object_cache() ) : ?>
<span class="wpdo-badge wpdo-badge-ok">External</span>
<?php else : ?>
<span class="wpdo-badge">Built-in</span>
<?php endif; ?>
<?php if ( $cache['flush_support'] ) : ?> <?php if ( $cache['flush_support'] ) : ?>
<span class="wpdo-badge wpdo-badge-ok">Flush Support</span> <span class="wpdo-badge wpdo-badge-ok">Flush Support</span>
<?php endif; ?> <?php endif; ?>
@@ -1726,13 +1733,13 @@ class TMDO_Admin {
↑ <?php esc_html_e( '升級模式', '2meet-data-optimizer' ); ?> ↑ <?php esc_html_e( '升級模式', '2meet-data-optimizer' ); ?>
</button> </button>
<button type="button" <button type="button"
class="button wpdo-btn-demote <?php echo $prev_mode ? '' : 'wpdo-eb-btn-dim'; ?>" class="button wpdo-btn-demote <?php echo esc_attr( $prev_mode ? '' : 'wpdo-eb-btn-dim' ); ?>"
data-wpdo-action="demote" data-wpdo-action="demote"
data-entity-type="<?php echo esc_attr( $etype ); ?>" data-entity-type="<?php echo esc_attr( $etype ); ?>"
data-prev-mode="<?php echo esc_attr( $prev_mode ?? '' ); ?>" data-prev-mode="<?php echo esc_attr( $prev_mode ?? '' ); ?>"
<?php disabled( ! $prev_mode ); ?> <?php disabled( ! $prev_mode ); ?>
title="<?php echo esc_attr( $prev_mode ? '降級到 ' . $prev_mode : '已在最低模式' ); ?>" title="<?php echo esc_attr( $prev_mode ? '降級到 ' . $prev_mode : '已在最低模式' ); ?>"
class="<?php echo $prev_mode ? '' : 'wpdo-eb-btn-dim'; ?>"> class="<?php echo esc_attr( $prev_mode ? '' : 'wpdo-eb-btn-dim' ); ?>">
↓ <?php esc_html_e( '降級模式', '2meet-data-optimizer' ); ?> ↓ <?php esc_html_e( '降級模式', '2meet-data-optimizer' ); ?>
</button> </button>
</div> </div>
@@ -2037,7 +2044,7 @@ class TMDO_Admin {
</div> </div>
<!-- Progress section (live) --> <!-- Progress section (live) -->
<div id="wpdo-st-progress-card" class="wpdo-eb-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>"> <div id="wpdo-st-progress-card" class="wpdo-eb-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3> <h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-bar-section"> <div class="wpdo-bar-section">
<div class="wpdo-bar-header"> <div class="wpdo-bar-header">
@@ -2071,7 +2078,7 @@ class TMDO_Admin {
</div> </div>
<!-- Benchmark report --> <!-- Benchmark report -->
<div id="wpdo-st-bench-card" class="wpdo-eb-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>"> <div id="wpdo-st-bench-card" class="wpdo-eb-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3> <h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
<div id="wpdo-st-bench-content"> <div id="wpdo-st-bench-content">
<?php if ( ! empty( $state['benchmark'] ) ) : ?> <?php if ( ! empty( $state['benchmark'] ) ) : ?>
@@ -3127,6 +3134,9 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
if ( isset( $_GET['wpdo_msg'] ) && 'settings_saved' === sanitize_key( wp_unslash( (string) $_GET['wpdo_msg'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['wpdo_msg'] ) && 'settings_saved' === sanitize_key( wp_unslash( (string) $_GET['wpdo_msg'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( '設定已儲存。', '2meet-data-optimizer' ) . '</p></div>'; echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( '設定已儲存。', '2meet-data-optimizer' ) . '</p></div>';
} }
foreach ( self::pull_bridge_mode_refusals() as $refused ) {
echo '<div class="notice notice-error"><p>' . esc_html__( '模式未變更:', '2meet-data-optimizer' ) . esc_html( $refused ) . '</p></div>';
}
$enabled = class_exists( 'TMDO_Email_Notifier' ) && TMDO_Email_Notifier::is_enabled(); $enabled = class_exists( 'TMDO_Email_Notifier' ) && TMDO_Email_Notifier::is_enabled();
$email = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::recipient() : ''; $email = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::recipient() : '';
$throttle = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::throttle_hours() : 24; $throttle = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::throttle_hours() : 24;
@@ -3726,22 +3736,33 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
} }
} }
private static function save_section_entity_bridge(): void { /**
* Save the Entity Bridge section (Hook Bus switch and bridge modes).
*
* @return string[] Refused changes, "<entity>:<reason>".
*/
private static function save_section_entity_bridge(): array {
update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false ); update_option( 'wpdo_hook_bus_enabled', isset( $_POST['wpdo_hook_bus_enabled'] ) ? '1' : '0', false );
if ( class_exists( 'TMDO_Mode_Manager' ) ) { $refused = self::apply_bridge_modes( self::posted_bridge_modes() );
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
$new_mode = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
: '';
if ( TMDO_Mode_Manager::is_valid_mode( $new_mode ) ) {
TMDO_Mode_Manager::set( $entity_type, $new_mode );
}
}
TMDO_Mode_Manager::reset_cache();
}
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) { if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
TMDO_Hook_Bus_Bridge::reset_cache(); TMDO_Hook_Bus_Bridge::reset_cache();
} }
return $refused;
}
/**
* Bridge modes posted by the settings form, keyed by entity type ('' when absent).
*
* @return array<string, string>
*/
private static function posted_bridge_modes(): array {
$posted = array();
foreach ( array( 'user', 'post', 'term', 'comment' ) as $entity_type ) {
$posted[ $entity_type ] = isset( $_POST[ 'wpdo_bridge_mode_' . $entity_type ] )
? sanitize_key( wp_unslash( (string) $_POST[ 'wpdo_bridge_mode_' . $entity_type ] ) )
: '';
}
return $posted;
} }
private static function save_section_hp_transient(): void { private static function save_section_hp_transient(): void {
@@ -3769,6 +3790,56 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
} }
// phpcs:enable WordPress.Security.NonceVerification.Missing,Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.FunctionComment.WrongStyle // phpcs:enable WordPress.Security.NonceVerification.Missing,Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.FunctionComment.WrongStyle
/**
* Apply posted bridge modes through TMDO_Mode_Manager::set(). Empty or
* unknown modes are skipped; refused changes (unsafe jump, Promotion Guard)
* are returned instead of being dropped silently (2.4.2 PR-135).
*
* @param array<string, string> $posted Mode per entity type.
* @return string[] Refused changes, "<entity>:<reason>".
*/
public static function apply_bridge_modes( array $posted ): array {
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
return array();
}
$refused = array();
foreach ( $posted as $entity_type => $new_mode ) {
if ( ! TMDO_Mode_Manager::is_valid_mode( (string) $new_mode ) ) {
continue;
}
$result = TMDO_Mode_Manager::set( (string) $entity_type, (string) $new_mode );
if ( is_wp_error( $result ) ) {
$refused[] = $entity_type . ':' . $result->get_error_message();
}
}
TMDO_Mode_Manager::reset_cache();
return $refused;
}
/**
* Keep refused bridge-mode changes for the page the settings form redirects to.
*
* @param string[] $refused Refused changes.
* @return void
*/
public static function remember_bridge_mode_refusals( array $refused ): void {
if ( $refused ) {
set_transient( 'wpdo_mode_refused_' . get_current_user_id(), $refused, 5 * MINUTE_IN_SECONDS );
}
}
/**
* Refused bridge-mode changes remembered for this user, once.
*
* @return string[]
*/
public static function pull_bridge_mode_refusals(): array {
$key = 'wpdo_mode_refused_' . get_current_user_id();
$refused = get_transient( $key );
delete_transient( $key );
return is_array( $refused ) ? $refused : array();
}
/** /**
* AJAX: save a single settings section without a full page reload. * AJAX: save a single settings section without a full page reload.
*/ */
@@ -3790,7 +3861,10 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
if ( ! isset( $handlers[ $section ] ) ) { if ( ! isset( $handlers[ $section ] ) ) {
wp_send_json_error( 'invalid_section', 400 ); wp_send_json_error( 'invalid_section', 400 );
} }
self::{$handlers[ $section ]}(); $refused = self::{$handlers[ $section ]}();
if ( is_array( $refused ) && $refused ) {
wp_send_json_error( array( 'message' => __( '部分模式未變更:', '2meet-data-optimizer' ) . implode( ';', $refused ) ) );
}
wp_send_json_success( array( 'message' => __( '設定已儲存。', '2meet-data-optimizer' ) ) ); wp_send_json_success( array( 'message' => __( '設定已儲存。', '2meet-data-optimizer' ) ) );
} }
} }
+1 -1
View File
@@ -246,7 +246,7 @@ class TMDO_Dashboard_Widget {
'%2$s' '%2$s'
); );
?> ?>
<p class="wpdo-widget-notice <?php echo $total_eav > 0 ? 'wpdo-widget-notice-warn' : 'wpdo-widget-notice-ok'; ?>"> <p class="wpdo-widget-notice <?php echo esc_attr( $total_eav > 0 ? 'wpdo-widget-notice-warn' : 'wpdo-widget-notice-ok' ); ?>">
📦 📦
<?php <?php
if ( $total_eav > 0 ) { if ( $total_eav > 0 ) {
@@ -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 )
)
)
);
}
}
+30 -15
View File
@@ -106,7 +106,7 @@ class TMDO_Setup_Wizard {
* @return void * @return void
*/ */
public static function register_menu_page(): void { public static function register_menu_page(): void {
add_submenu_page( $hook_suffix = add_submenu_page(
'tools.php', 'tools.php',
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ), __( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ), __( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
@@ -114,6 +114,33 @@ class TMDO_Setup_Wizard {
'wpdo-setup-wizard', 'wpdo-setup-wizard',
array( __CLASS__, 'render_page' ) array( __CLASS__, 'render_page' )
); );
if ( $hook_suffix ) {
add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_snapshot_post' ) );
}
}
/**
* Step 4 baseline snapshot (POST + nonce, prevents Referer leak — v3.3.x
* P1-6b hardening), handled on load-{page} so the redirect runs before any
* output.
*
* @return void
*/
public static function handle_snapshot_post(): void {
if ( ! isset( $_POST['wpdo_take_snapshot'] ) || ! TMDO_Capability::current_user_can_admin() || ! class_exists( 'TMDO_Snapshot_Manager' ) ) {
return;
}
check_admin_referer( self::NONCE_NAME );
$result = TMDO_Snapshot_Manager::create(
'manual',
array(),
array(
'notes' => 'wizard_baseline',
'retention_days' => 365,
)
);
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&' . ( empty( $result['ok'] ) ? 'snap_failed=1' : 'snap_done=1' ) ) );
exit;
} }
/** /**
@@ -356,20 +383,8 @@ class TMDO_Setup_Wizard {
</p> </p>
</form> </form>
<?php <?php
// Take snapshot if action requested (POST + nonce, prevents Referer leak — v3.3.x P1-6b hardening). // The snapshot itself is taken in handle_snapshot_post(), before any output.
if ( isset( $_POST['wpdo_take_snapshot'] ) && check_admin_referer( self::NONCE_NAME ) && class_exists( 'TMDO_Snapshot_Manager' ) ) { if ( isset( $_GET['snap_failed'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$result = TMDO_Snapshot_Manager::create(
'manual',
array(),
array(
'notes' => 'wizard_baseline',
'retention_days' => 365,
)
);
if ( ! empty( $result['ok'] ) ) {
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&snap_done=1' ) );
exit;
}
echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>'; echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>';
} }
?> ?>
+2 -2
View File
@@ -211,7 +211,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
</div> </div>
<!-- Progress card (live) --> <!-- Progress card (live) -->
<div id="wpdo-cst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>"> <div id="wpdo-cst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3> <h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-bar-section"> <div class="wpdo-bar-section">
<div class="wpdo-bar-header"> <div class="wpdo-bar-header">
@@ -249,7 +249,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
</div> </div>
<!-- Benchmark report --> <!-- Benchmark report -->
<div id="wpdo-cst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>"> <div id="wpdo-cst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3> <h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
<div id="wpdo-cst-bench-content"></div> <div id="wpdo-cst-bench-content"></div>
</div> </div>
+3 -3
View File
@@ -99,7 +99,7 @@ $failed = 'failed' === $state;
<!-- Run-migration panel (idle state) ──────────────────────────────── --> <!-- Run-migration panel (idle state) ──────────────────────────────── -->
<section class="wpdo-mw-panel wpdo-mw-run" id="wpdo-mw-run-panel" <section class="wpdo-mw-panel wpdo-mw-run" id="wpdo-mw-run-panel"
<?php echo $active || $completed ? 'hidden' : ''; ?>> <?php echo esc_attr( $active || $completed ? 'hidden' : '' ); ?>>
<?php if ( 0 === $eav_residue && 'aeav_only' === $current_mode ) : ?> <?php if ( 0 === $eav_residue && 'aeav_only' === $current_mode ) : ?>
<div class="wpdo-mw-nothing-todo"> <div class="wpdo-mw-nothing-todo">
@@ -170,7 +170,7 @@ $failed = 'failed' === $state;
<!-- Live progress panel (running state) ───────────────────────────── --> <!-- Live progress panel (running state) ───────────────────────────── -->
<section class="wpdo-mw-panel wpdo-mw-progress" id="wpdo-mw-progress-panel" <section class="wpdo-mw-panel wpdo-mw-progress" id="wpdo-mw-progress-panel"
<?php echo $active || $failed ? '' : 'hidden'; ?>> <?php echo esc_attr( $active || $failed ? '' : 'hidden' ); ?>>
<h3 id="wpdo-mw-progress-title"><?php esc_html_e( '執行中…', '2meet-data-optimizer' ); ?></h3> <h3 id="wpdo-mw-progress-title"><?php esc_html_e( '執行中…', '2meet-data-optimizer' ); ?></h3>
@@ -210,7 +210,7 @@ $failed = 'failed' === $state;
<!-- Completed state ───────────────────────────────────────────────── --> <!-- Completed state ───────────────────────────────────────────────── -->
<section class="wpdo-mw-panel wpdo-mw-done" id="wpdo-mw-done-panel" <section class="wpdo-mw-panel wpdo-mw-done" id="wpdo-mw-done-panel"
<?php echo $completed ? '' : 'hidden'; ?>> <?php echo esc_attr( $completed ? '' : 'hidden' ); ?>>
<h3>✅ <?php esc_html_e( '遷移完成', '2meet-data-optimizer' ); ?></h3> <h3>✅ <?php esc_html_e( '遷移完成', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-mw-done-summary" id="wpdo-mw-done-summary"></div> <div class="wpdo-mw-done-summary" id="wpdo-mw-done-summary"></div>
<button type="button" class="button" id="wpdo-mw-reset"><?php esc_html_e( '回到診斷頁', '2meet-data-optimizer' ); ?></button> <button type="button" class="button" id="wpdo-mw-reset"><?php esc_html_e( '回到診斷頁', '2meet-data-optimizer' ); ?></button>
+1 -1
View File
@@ -218,7 +218,7 @@ $wpdo_format_msg = static function ( string $code ): string {
<form method="post"> <form method="post">
<input type="hidden" name="wpdo_postmeta_cleanup" value="1"> <input type="hidden" name="wpdo_postmeta_cleanup" value="1">
<?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?> <?php wp_nonce_field( 'wpdo_postmeta_cleanup' ); ?>
<button type="submit" class="button button-primary <?php echo (int) $garbage['total'] > 0 ? '' : 'disabled'; ?>" <button type="submit" class="button button-primary <?php echo esc_attr( (int) $garbage['total'] > 0 ? '' : 'disabled' ); ?>"
onclick="return confirm(<?php echo wp_json_encode( __( '確定要刪除 wp_postmeta 中的垃圾資料?此動作不可逆。', '2meet-data-optimizer' ) ); ?>);"> onclick="return confirm(<?php echo wp_json_encode( __( '確定要刪除 wp_postmeta 中的垃圾資料?此動作不可逆。', '2meet-data-optimizer' ) ); ?>);">
<?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?> <?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?>
</button> </button>
+6 -6
View File
@@ -161,22 +161,22 @@ $post_type_options = array(
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr class="<?php echo 'disabled' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>"> <tr class="<?php echo esc_attr( 'disabled' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
<td><code>disabled</code></td> <td><code>disabled</code></td>
<td>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td> <td>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td>
<td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td> <td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td>
</tr> </tr>
<tr class="<?php echo 'dual_write' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>"> <tr class="<?php echo esc_attr( 'dual_write' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
<td><code>dual_write</code></td> <td><code>dual_write</code></td>
<td>wp_postmeta 5 / flat 0 → 1:5</td> <td>wp_postmeta 5 / flat 0 → 1:5</td>
<td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td> <td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td>
</tr> </tr>
<tr class="<?php echo 'shadow_read' === $current_post_mode ? 'wpdo-tr-highlight' : ''; ?>"> <tr class="<?php echo esc_attr( 'shadow_read' === $current_post_mode ? 'wpdo-tr-highlight' : '' ); ?>">
<td><code>shadow_read</code></td> <td><code>shadow_read</code></td>
<td>wp_postmeta 5 / flat 0 → 1:5</td> <td>wp_postmeta 5 / flat 0 → 1:5</td>
<td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td> <td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td>
</tr> </tr>
<tr class="<?php echo 'aeav_only' === $current_post_mode ? 'wpdo-tr-success' : ''; ?>"> <tr class="<?php echo esc_attr( 'aeav_only' === $current_post_mode ? 'wpdo-tr-success' : '' ); ?>">
<td><code>aeav_only</code></td> <td><code>aeav_only</code></td>
<td>wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️</td> <td>wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️</td>
<td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td> <td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td>
@@ -291,7 +291,7 @@ $post_type_options = array(
</div> </div>
<!-- Progress section (live) --> <!-- Progress section (live) -->
<div id="wpdo-pst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>"> <div id="wpdo-pst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3> <h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-bar-section"> <div class="wpdo-bar-section">
<div class="wpdo-bar-header"> <div class="wpdo-bar-header">
@@ -329,7 +329,7 @@ $post_type_options = array(
</div> </div>
<!-- Benchmark report --> <!-- Benchmark report -->
<div id="wpdo-pst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>"> <div id="wpdo-pst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3> <h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
<div id="wpdo-pst-bench-content"></div> <div id="wpdo-pst-bench-content"></div>
</div> </div>
+2 -2
View File
@@ -207,7 +207,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
</div> </div>
<!-- Progress card (live) --> <!-- Progress card (live) -->
<div id="wpdo-tst-progress-card" class="wpdo-card wpdo-mt-3<?php echo $is_running ? '' : ' wpdo-hidden'; ?>"> <div id="wpdo-tst-progress-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( $is_running ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3> <h3 class="wpdo-mt-0"><?php esc_html_e( '2. 即時進度', '2meet-data-optimizer' ); ?></h3>
<div class="wpdo-bar-section"> <div class="wpdo-bar-section">
<div class="wpdo-bar-header"> <div class="wpdo-bar-header">
@@ -245,7 +245,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
</div> </div>
<!-- Benchmark report --> <!-- Benchmark report -->
<div id="wpdo-tst-bench-card" class="wpdo-card wpdo-mt-3<?php echo ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden'; ?>"> <div id="wpdo-tst-bench-card" class="wpdo-card wpdo-mt-3<?php echo esc_attr( ! empty( $state['benchmark'] ) ? '' : ' wpdo-hidden' ); ?>">
<h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3> <h3 class="wpdo-mt-0"><?php esc_html_e( '3. Benchmark 報告', '2meet-data-optimizer' ); ?></h3>
<div id="wpdo-tst-bench-content"></div> <div id="wpdo-tst-bench-content"></div>
</div> </div>
+39 -29
View File
@@ -9,8 +9,8 @@
* wp wpdo member-shadow-report — Recent entity_type=user diff records. * wp wpdo member-shadow-report — Recent entity_type=user diff records.
* wp wpdo member-cutover — Switch user mode to aeav_only. * wp wpdo member-cutover — Switch user mode to aeav_only.
* wp wpdo member-points-check — Show balance + recent ledger for a user. * wp wpdo member-points-check — Show balance + recent ledger for a user.
* wp wpdo member-sso-sync — Sync Hub membership claims to flat table. * wp wpdo member-sso-sync — (deprecated) Sync Hub membership claims to flat table.
* wp wpdo member-force-logout — Set token_expires_at to past → re-auth. * wp wpdo member-force-logout — (deprecated) Set token_expires_at to past → re-auth.
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 2.5.5 * @since 2.5.5
@@ -35,24 +35,6 @@ if ( ! class_exists( 'WP_CLI' ) ) {
*/ */
class TMDO_CLI_Member { class TMDO_CLI_Member {
/**
* Valid group names managed by this CLI.
*
* V2.7.0: Extended with core_profile/social/commerce/hp_user to absorb
* the legacy WP/WC/HP wp_usermeta keys.
*/
private const VALID_GROUPS = array(
'membership',
'activity',
'profile',
'sso',
'core_profile',
'social',
'commerce',
'hp_user',
'admin_prefs',
);
// ── member-audit ───────────────────────────────────────────────────────── // ── member-audit ─────────────────────────────────────────────────────────
/** /**
@@ -73,7 +55,7 @@ class TMDO_CLI_Member {
WP_CLI::log( '' ); WP_CLI::log( '' );
$items = array(); $items = array();
foreach ( self::VALID_GROUPS as $group ) { foreach ( TMDO_Entity_Registry::get_groups_for_type( 'user' ) as $group ) {
$table = $wpdb->prefix . 'wpdo_user_' . $group; $table = $wpdb->prefix . 'wpdo_user_' . $group;
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) $wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
@@ -111,7 +93,7 @@ class TMDO_CLI_Member {
// ── member-backfill ─────────────────────────────────────────────────────── // ── member-backfill ───────────────────────────────────────────────────────
/** /**
* Backfill wp_usermeta rows into the specified user flat-table group. * Backfill wp_usermeta rows into one user flat-table group, or all of them.
* *
* Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based, * Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based,
* 500 rows/batch, checkpoint stored in wpdo_migration_status. * 500 rows/batch, checkpoint stored in wpdo_migration_status.
@@ -119,7 +101,10 @@ class TMDO_CLI_Member {
* ## OPTIONS * ## OPTIONS
* *
* [--group=<group>] * [--group=<group>]
* : Which group to backfill: membership, activity, profile, sso. Default: membership. * : Registered user group to backfill (e.g. membership, activity, core_profile). Default: membership.
*
* [--all]
* : Backfill every registered user group (restored orphan groups excluded).
* *
* [--batch-size=<n>] * [--batch-size=<n>]
* : Rows per batch (default 500, max 2000). * : Rows per batch (default 500, max 2000).
@@ -134,21 +119,38 @@ class TMDO_CLI_Member {
* *
* wp wpdo member-backfill --group=membership --dry-run * wp wpdo member-backfill --group=membership --dry-run
* wp wpdo member-backfill --group=membership * wp wpdo member-backfill --group=membership
* wp wpdo member-backfill --group=sso --reset * wp wpdo member-backfill --all
* *
* @param array $args Positional arguments. * @param array $args Positional arguments.
* @param array $assoc_args Named arguments. * @param array $assoc_args Named arguments.
*/ */
public function member_backfill( $args, $assoc_args ): void { public function member_backfill( $args, $assoc_args ): void {
$group = (string) ( $assoc_args['group'] ?? 'membership' );
$batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) ); $batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) );
$dry_run = isset( $assoc_args['dry-run'] ); $dry_run = isset( $assoc_args['dry-run'] );
$reset = isset( $assoc_args['reset'] ); $reset = isset( $assoc_args['reset'] );
$valid = TMDO_Entity_Migration_Engine::backfillable_groups( 'user' );
if ( ! in_array( $group, self::VALID_GROUPS, true ) ) { if ( isset( $assoc_args['all'] ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) ); if ( isset( $assoc_args['group'] ) ) {
WP_CLI::error( 'Use either --group or --all, not both.' );
}
$groups = $valid;
} else {
$groups = array( (string) ( $assoc_args['group'] ?? 'membership' ) );
if ( ! in_array( $groups[0], $valid, true ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid ) );
}
} }
foreach ( $groups as $group ) {
$this->backfill_group( $group, $batch_size, $dry_run, $reset );
}
}
/**
* Backfill one user group and report the result.
*/
private function backfill_group( string $group, int $batch_size, bool $dry_run, bool $reset ): void {
if ( $reset ) { if ( $reset ) {
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group ); TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
WP_CLI::log( "Checkpoint cleared for user/{$group}." ); WP_CLI::log( "Checkpoint cleared for user/{$group}." );
@@ -298,8 +300,8 @@ class TMDO_CLI_Member {
$result = TMDO_Mode_Manager::set( 'user', 'aeav_only' ); $result = TMDO_Mode_Manager::set( 'user', 'aeav_only' );
if ( isset( $result['error'] ) ) { if ( is_wp_error( $result ) ) {
WP_CLI::error( 'Cutover failed: ' . $result['error'] ); WP_CLI::error( 'Cutover failed: ' . $result->get_error_message() );
} }
WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' ); WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' );
@@ -356,6 +358,9 @@ class TMDO_CLI_Member {
/** /**
* Sync Hub membership claims into the user flat table. * Sync Hub membership claims into the user flat table.
* *
* Deprecated since 1.2.0: the user `sso` group is no longer registered, so
* its flat table is never created on new installs. Will be removed.
*
* Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token * Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token
* from usermeta and writes them into the sso group flat table. * from usermeta and writes them into the sso group flat table.
* Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing * Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing
@@ -381,6 +386,7 @@ class TMDO_CLI_Member {
* @param array $assoc_args Named arguments. * @param array $assoc_args Named arguments.
*/ */
public function member_sso_sync( $args, $assoc_args ): void { public function member_sso_sync( $args, $assoc_args ): void {
WP_CLI::warning( 'member-sso-sync is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' );
global $wpdb; global $wpdb;
$user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0; $user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0;
@@ -466,6 +472,9 @@ class TMDO_CLI_Member {
/** /**
* Force re-authentication by setting token_expires_at to a past datetime. * Force re-authentication by setting token_expires_at to a past datetime.
* *
* Deprecated since 1.2.0: works on the user `sso` flat table, which is no
* longer registered. Will be removed.
*
* The Spoke silent-refresh logic detects an expired token_expires_at and * The Spoke silent-refresh logic detects an expired token_expires_at and
* redirects the user to re-authenticate against the Hub. If the Hub has * redirects the user to re-authenticate against the Hub. If the Hub has
* the account blocked, the user is logged out from all Spokes. * the account blocked, the user is logged out from all Spokes.
@@ -490,6 +499,7 @@ class TMDO_CLI_Member {
* @param array $assoc_args Named arguments. * @param array $assoc_args Named arguments.
*/ */
public function member_force_logout( $args, $assoc_args ): void { public function member_force_logout( $args, $assoc_args ): void {
WP_CLI::warning( 'member-force-logout is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' );
if ( ! isset( $assoc_args['confirm'] ) ) { if ( ! isset( $assoc_args['confirm'] ) ) {
WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' ); WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' );
} }
+55 -15
View File
@@ -198,29 +198,20 @@ class TMDO_CLI_Post {
/** /**
* Backfill one post entity group from wp_postmeta into its flat table * Backfill one post entity group from wp_postmeta into its flat table
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE). * via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE). Groups with
* JSON-typed fields are backfilled row by row instead.
* *
* Idempotent — safe to re-run. Skips JSON-typed fields (handled by the * Idempotent — safe to re-run.
* row-by-row backfill phase in v2.9.4+).
* *
* ## OPTIONS * ## OPTIONS
* *
* --group=<group> * --group=<group>
* : Entity group name. Required. * : Registered post entity group name (e.g. wp_core, attachment,
* --- * hp_listing_core). Required.
* options:
* - wp_core
* - attachment
* - wc_product
* - hp_listing_core
* - hp_request_core
* - hp_vendor_core
* - nav_menu_item
* ---
* *
* ## EXAMPLES * ## EXAMPLES
* *
* wp wpdo post-migrate-group --group=wc_product * wp wpdo post-migrate-group --group=hp_listing_core
* wp wpdo post-migrate-group --group=hp_listing_core * wp wpdo post-migrate-group --group=hp_listing_core
* *
* @param array $args Positional arguments (unused). * @param array $args Positional arguments (unused).
@@ -439,6 +430,10 @@ class TMDO_CLI_Post {
* Safe to run with the filter enabled; the filter prevents new postmeta * Safe to run with the filter enabled; the filter prevents new postmeta
* writes from re-bloating the table. * writes from re-bloating the table.
* *
* With --options it removes instead the rows the filter wrote to wp_options
* (`_transient_wpdo_hp_pm_*`) before 1.2.0 / AddOn 0.3.0 kept them in the
* object cache; works without the AddOn active.
*
* ## OPTIONS * ## OPTIONS
* *
* [--dry-run] * [--dry-run]
@@ -447,10 +442,17 @@ class TMDO_CLI_Post {
* [--confirm] * [--confirm]
* : Required to actually DELETE rows. * : Required to actually DELETE rows.
* *
* [--options]
* : Clean the AddOn's rows in wp_options instead of wp_postmeta.
*
* [--batch=<n>]
* : Rows per DELETE with --options. Default 5000.
*
* ## EXAMPLES * ## EXAMPLES
* *
* wp wpdo cleanup-hp-transients --dry-run * wp wpdo cleanup-hp-transients --dry-run
* wp wpdo cleanup-hp-transients --confirm * wp wpdo cleanup-hp-transients --confirm
* wp wpdo cleanup-hp-transients --options --dry-run
* *
* @param array $args Positional arguments (unused). * @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments. * @param array $assoc_args Named arguments.
@@ -470,6 +472,11 @@ class TMDO_CLI_Post {
); );
} }
if ( isset( $assoc_args['options'] ) ) {
self::cleanup_hp_transient_options( $dry_run, max( 1, (int) ( $assoc_args['batch'] ?? 5000 ) ) );
return;
}
if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) { if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' ); WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' );
} }
@@ -503,6 +510,39 @@ class TMDO_CLI_Post {
); );
} }
/**
* Count or delete the HivePress AddOn's transient rows in wp_options, in
* batches (dev36 held 1.15 M of them).
*
* @param bool $dry_run Count only.
* @param int $batch Rows per DELETE.
* @return void
*/
private static function cleanup_hp_transient_options( bool $dry_run, int $batch ): void {
global $wpdb;
// TMDO_Hivepress_Transient_Filter::TRANSLATED_NAMESPACE (AddOn), repeated
// here so the leftovers can be removed with the AddOn gone.
$ns = $wpdb->esc_like( 'wpdo_hp_pm_' );
$where = $wpdb->prepare( 'option_name LIKE %s OR option_name LIKE %s', '\_transient\_' . $ns . '%', '\_transient\_timeout\_' . $ns . '%' );
if ( $dry_run ) {
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->options} WHERE {$where}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
WP_CLI::success( sprintf( '[dry-run] %d HivePress transient row(s) in wp_options would be deleted. Re-run with --confirm.', $count ) );
return;
}
$deleted = 0;
do {
$rows = (int) $wpdb->query( "DELETE FROM {$wpdb->options} WHERE {$where} LIMIT {$batch}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$deleted += $rows;
} while ( $rows === $batch );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info( 'cleanup_hp_transient_options', array( 'deleted' => $deleted ) );
}
WP_CLI::success( sprintf( 'Deleted %d HivePress transient row(s) from wp_options.', $deleted ) );
}
// ── post-cutover-legacy (v2.9.5) ────────────────────────────────────────── // ── post-cutover-legacy (v2.9.5) ──────────────────────────────────────────
/** /**
+13 -9
View File
@@ -442,9 +442,9 @@ class TMDO_CLI_Term_Comment {
WP_CLI::log( '' ); WP_CLI::log( '' );
WP_CLI::log( '── Filter chain status ──' ); WP_CLI::log( '── Filter chain status ──' );
self::print_filter_status( 'wpdo_term_comment_garbage_filter_enabled', 'Garbage filter (Phase 1)' ); self::print_filter_status( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter (Phase 1)' );
self::print_filter_status( 'wpdo_wc_term_count_filter_enabled', 'WC term count filter (Phase 3)' ); self::print_filter_status( 'TMDO_WC_Term_Count_Filter', 'WC term count filter (Phase 3)' );
self::print_filter_status( 'wpdo_term_comment_misc_bucket_enabled', 'Misc bucket (Phase 4)' ); self::print_filter_status( 'TMDO_Term_Comment_Misc_Bucket', 'Misc bucket (Phase 4)' );
WP_CLI::log( '' ); WP_CLI::log( '' );
WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) ); WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) );
@@ -504,15 +504,19 @@ class TMDO_CLI_Term_Comment {
} }
/** /**
* Print a filter's enabled/disabled status. * Print a filter's enabled/disabled status, as the filter itself decides it
* (each has its own default).
* *
* @param string $option_key Option storing the toggle value. * @param string $filter_class Filter class with a static is_enabled().
* @param string $label Human-readable name. * @param string $label Human-readable name.
* @return void * @return void
*/ */
private static function print_filter_status( string $option_key, string $label ): void { private static function print_filter_status( string $filter_class, string $label ): void {
$enabled = (bool) get_option( $option_key, '1' ); if ( ! class_exists( $filter_class ) ) {
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $enabled ? '✓ enabled' : '✗ disabled' ) ); WP_CLI::log( sprintf( '%-32s%s', $label . ':', '— not installed' ) );
return;
}
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $filter_class::is_enabled() ? '✓ enabled' : '✗ disabled' ) );
} }
// ── term-comment-shadow-report (v2.12.5 Phase 5) ────────────────────────── // ── term-comment-shadow-report (v2.12.5 Phase 5) ──────────────────────────
+217 -10
View File
@@ -91,22 +91,29 @@ class TMDO_CLI_V2 {
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table ) $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table )
); );
$flat_count = $flat_exists // Flat rows holding at least one value (wp_core keeps an all-NULL row per post).
$managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
$has_value = implode( ' OR ', array_map( static fn( $k ) => '`' . TMDO_Schema_Manager::sanitize_column_name( $k ) . '` IS NOT NULL', $managed_keys ) );
$flat_count = $flat_exists && '' !== $has_value
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" ) ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}` WHERE {$has_value}" )
: 0; : 0;
// EAV source: count distinct entity IDs that have at least one managed key. // EAV source: count distinct entity IDs that have at least one managed key.
$managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name ); $eav_table = $eav_tables[ $entity_type ] ?? '';
$eav_table = $eav_tables[ $entity_type ] ?? ''; $eav_count = 0;
$eav_count = 0;
if ( '' !== $eav_table && ! empty( $managed_keys ) ) { if ( '' !== $eav_table && ! empty( $managed_keys ) ) {
$id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id"; $id_col = ( 'post' === $entity_type ) ? 'post_id' : "{$entity_type}_id";
$placeholders = implode( ', ', array_fill( 0, count( $managed_keys ), '%s' ) ); $placeholders = implode( ', ', array_fill( 0, count( $managed_keys ), '%s' ) );
// $id_col comes from a hardcoded map; $eav_table is $wpdb->*meta (framework-managed); $placeholders is %s repeats only. // $id_col comes from a hardcoded map; $eav_table is $wpdb->*meta (framework-managed); $placeholders is %s repeats only.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$sql = "SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$eav_table}` WHERE `meta_key` IN ({$placeholders})"; $sql = "SELECT COUNT(DISTINCT m.`{$id_col}`) FROM `{$eav_table}` m WHERE m.`meta_key` IN ({$placeholders})";
// A group of some post types only: other posts holding its keys are not its rows.
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( 'post', $group_name ) : array();
if ( $post_types ) {
$sql .= " AND m.post_id IN ( SELECT ID FROM {$wpdb->posts} WHERE post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "') )";
}
$eav_count = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$managed_keys ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $eav_count = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$managed_keys ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
} }
@@ -152,6 +159,131 @@ class TMDO_CLI_V2 {
WP_CLI::success( "Hook Bus: {$state}" ); WP_CLI::success( "Hook Bus: {$state}" );
} }
/**
* Set an entity's bridge mode. Goes through TMDO_Mode_Manager::set(): unsafe
* jumps and promotions refused by the Promotion Guard (aeav_only on
* HivePress / WooCommerce sites) fail and leave the mode unchanged.
*
* ## OPTIONS
*
* <entity>
* : post | user | term | comment
*
* <mode>
* : disabled | dual_write | shadow_read | aeav_only
*
* ## EXAMPLES
*
* wp tmdo bridge-mode user dual_write
* wp tmdo bridge-mode post disabled
*
* @param array<int, string> $args Entity type and mode.
* @param array<string, string> $assoc_args Unused.
*/
public function bridge_mode( $args, $assoc_args ): void {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$entity = (string) ( $args[0] ?? '' );
$mode = (string) ( $args[1] ?? '' );
$from = TMDO_Mode_Manager::get( $entity );
$result = TMDO_Mode_Manager::set( $entity, $mode );
if ( is_wp_error( $result ) ) {
WP_CLI::error( $result->get_error_message() );
}
WP_CLI::success( "{$entity}: {$from} → {$mode}" );
}
/**
* Compare an entity's flat tables with native meta, optionally repairing them.
*
* Reports per key how many values are missing from flat (miss), differ
* (diff) or exist only in flat (extra), and records the outcome per group
* in the wpdo_drift_status option. Read-only unless --fix is given.
*
* ## OPTIONS
*
* <entity>
* : post | user | term | comment
*
* [--group=<group>]
* : Only this group. Default: every backfillable group.
*
* [--sample=<n>]
* : Check n random entities per group instead of all of them.
*
* [--fix]
* : Rebuild drifted flat rows from native meta (native is never written),
* delete rows of deleted entities / other post types, then check again.
* Not combinable with --sample.
*
* [--format=<format>]
* : table or json. Default: table.
*
* ## EXAMPLES
*
* wp tmdo drift-check post
* wp tmdo drift-check user --group=activity
* wp tmdo drift-check post --sample=500
* wp tmdo drift-check post --fix
*
* @param array<int, string> $args Entity type.
* @param array<string, string> $assoc_args Options.
*/
public function drift_check( $args, $assoc_args ): void {
$entity = (string) ( $args[0] ?? '' );
$group = (string) ( $assoc_args['group'] ?? '' );
$sample = max( 0, (int) ( $assoc_args['sample'] ?? 0 ) );
$fix = isset( $assoc_args['fix'] );
if ( $fix && $sample ) {
WP_CLI::error( '--fix repairs every entity; it cannot be combined with --sample.' );
return;
}
try {
if ( $fix ) {
$result = TMDO_Drift_Check::fix( $entity, $group );
$report = $result['after'];
WP_CLI::log( sprintf( 'Fixed: %d row(s) rebuilt from native, %d cleared, %d stray row(s) deleted.', $result['fixed']['rebuilt'], $result['fixed']['cleared'], $result['fixed']['deleted_rows'] ) );
} else {
$report = TMDO_Drift_Check::run( $entity, $group, $sample );
}
} catch ( InvalidArgumentException | RuntimeException $e ) {
WP_CLI::error( $e->getMessage() );
return;
}
if ( 'json' === ( $assoc_args['format'] ?? 'table' ) ) {
WP_CLI::line( (string) wp_json_encode( $report ) );
return;
}
$rows = array();
foreach ( $report['groups'] as $group => $result ) {
foreach ( $result['keys'] as $key => $counts ) {
if ( $counts['miss'] + $counts['diff'] + $counts['extra'] > 0 ) {
$rows[] = array_merge(
array(
'group' => $group,
'key' => $key,
),
$counts
);
}
}
}
if ( $rows ) {
\WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'key', 'miss', 'diff', 'extra' ) );
}
$total = $report['total'];
$summary = sprintf( '%s: %d group(s)%s — miss=%d diff=%d extra=%d', $entity, count( $report['groups'] ), $sample ? " (sample {$sample})" : '', $total['miss'], $total['diff'], $total['extra'] );
if ( $total['miss'] + $total['diff'] + $total['extra'] > 0 ) {
WP_CLI::warning( $summary );
return;
}
WP_CLI::success( $summary );
}
/** /**
* Write an entity's flat-table values back to native meta — the way out of * Write an entity's flat-table values back to native meta — the way out of
* aeav_only. Resumable; re-run after an interruption. * aeav_only. Resumable; re-run after an interruption.
@@ -162,7 +294,15 @@ class TMDO_CLI_V2 {
* : post | user | term | comment * : post | user | term | comment
* *
* [--dry-run] * [--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>] * [--batch=<n>]
* : Rows per batch. Default 500. * : Rows per batch. Default 500.
@@ -190,17 +330,34 @@ class TMDO_CLI_V2 {
public function bridge_restore_native( $args, $assoc_args ): void { public function bridge_restore_native( $args, $assoc_args ): void {
$entity = (string) ( $args[0] ?? '' ); $entity = (string) ( $args[0] ?? '' );
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) { if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--batch=<n>] [--demote] [--group=<group> [--retire]]' ); 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'] ); $dry_run = isset( $assoc_args['dry-run'] );
$group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) ); $group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) );
if ( isset( $assoc_args['retire'] ) && '' === $group ) { if ( isset( $assoc_args['retire'] ) && '' === $group ) {
WP_CLI::error( '--retire needs --group=<group>.' ); WP_CLI::error( '--retire needs --group=<group>.' );
} }
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group ); 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 ) { 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 ) { if ( $dry_run ) {
WP_CLI::success( '[dry-run] Nothing written.' ); WP_CLI::success( '[dry-run] Nothing written.' );
@@ -217,6 +374,50 @@ class TMDO_CLI_V2 {
WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' ); 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. * Audit per-module state + shadow_read flags.
* *
@@ -849,8 +1050,14 @@ WP_CLI::add_command( 'wpdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status'
WP_CLI::add_command( 'tmdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) ); WP_CLI::add_command( 'tmdo bridge-status', array( 'TMDO_CLI_V2', 'bridge_status' ) );
WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'wpdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
WP_CLI::add_command( 'tmdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
WP_CLI::add_command( 'wpdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
WP_CLI::add_command( 'tmdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) ); WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) ); WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
WP_CLI::add_command( '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( '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( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) ); WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) );
+91 -23
View File
@@ -94,6 +94,92 @@ class TMDO_CLI {
WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) ); WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) );
} }
/**
* Report the flat table of every backfillable group of every entity type,
* plus the installer's migration status and points ledger tables.
*
* @return bool Every table exists.
*/
public static function check_entity_tables(): bool {
global $wpdb;
$tables = array( $wpdb->prefix . 'wpdo_migration_status', $wpdb->prefix . 'wpdo_user_points_ledger' );
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
foreach ( TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type ) as $group ) {
$tables[] = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
}
}
$ok = true;
foreach ( $tables as $t ) {
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
} else {
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
}
if ( $exists ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
} else {
WP_CLI::warning( " [MISSING] {$t} — run `wp wpdo install` to create." );
$ok = false;
}
}
return $ok;
}
/**
* Report Promotion Guard violations and the recorded drift status of every
* entity that writes flat tables or has been checked (informational).
*/
public static function report_guard_and_drift(): void {
WP_CLI::log( '' );
$violations = TMDO_Promotion_Guard::violations();
if ( empty( $violations ) ) {
WP_CLI::log( ' [OK] Promotion Guard: no violation' );
}
foreach ( $violations as $entity_type => $integrations ) {
WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." );
}
$deferred = array_keys( (array) get_option( TMDO_Schema_Manager::DEFERRED_OPTION, array() ) );
if ( $deferred ) {
WP_CLI::warning( ' [SCHEMA] groups using native meta until their table upgrade and the drift resync after it (tmdo_drift_resync) are done: ' . implode( ', ', $deferred ) . '.' );
}
$pending = TMDO_Schema_Manager::pending_indexes();
if ( $pending ) {
WP_CLI::warning( ' [INDEX] declared indexes not added yet: ' . implode( ', ', $pending ) . ' — still missing after this run: another process is adding them, or the ALTER failed (see the `schema.index_failed` log).' );
}
$status = get_option( TMDO_Drift_Check::OPTION, array() );
$status = is_array( $status ) ? $status : array();
WP_CLI::log( 'Drift (last `wp tmdo drift-check` or daily sample):' );
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
// Records of groups no longer registered can be neither re-checked nor fixed.
$last = array_intersect_key( (array) ( $status[ $entity_type ] ?? array() ), array_flip( $groups ) );
if ( empty( $groups ) || ( empty( $last ) && ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) ) {
continue;
}
if ( empty( $last ) ) {
WP_CLI::log( " {$entity_type}: never checked — run `wp tmdo drift-check {$entity_type}`" );
continue;
}
$drifting = array();
foreach ( $last as $group => $s ) {
if ( (int) $s['miss'] + (int) $s['diff'] + (int) $s['extra'] > 0 ) {
$drifting[] = sprintf( '%s miss=%d diff=%d extra=%d', $group, $s['miss'], $s['diff'], $s['extra'] );
}
}
$oldest = min( array_column( $last, 'checked_at' ) );
if ( $drifting ) {
WP_CLI::warning( " {$entity_type}: " . implode( '; ', $drifting ) . " (checked {$oldest} UTC or later) — `wp tmdo drift-check {$entity_type} --fix`" );
} else {
WP_CLI::log( sprintf( ' [OK] %s: %d group(s) clean (oldest check %s UTC)', $entity_type, count( $last ), $oldest ) );
}
}
}
/** /**
* Run health check on all tables. * Run health check on all tables.
* *
@@ -195,30 +281,12 @@ class TMDO_CLI {
WP_CLI::log( $exists ? " [OK] {$t}" : " [NOT CREATED] {$t} (will be created on first use)" ); WP_CLI::log( $exists ? " [OK] {$t}" : " [NOT CREATED] {$t} (will be created on first use)" );
} }
// Check user entity flat tables (v2.5.5+). // Entity flat tables, from the registry (was a hard-coded user list that
$user_entity_tables = array( // named the never-registered sso group).
$wpdb->prefix . 'wpdo_user_membership', if ( ! self::check_entity_tables() ) {
$wpdb->prefix . 'wpdo_user_activity', $all_ok = false;
$wpdb->prefix . 'wpdo_user_profile',
$wpdb->prefix . 'wpdo_user_sso',
$wpdb->prefix . 'wpdo_user_points_ledger',
$wpdb->prefix . 'wpdo_migration_status',
);
foreach ( $user_entity_tables as $t ) {
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
} else {
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
}
if ( $exists ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
} else {
WP_CLI::warning( " [MISSING] {$t} — run \`wp wpdo install\` to create." );
$all_ok = false;
}
} }
self::report_guard_and_drift();
// Check partner plugin custom tables via Custom Table Registry (v2.6.0+). // Check partner plugin custom tables via Custom Table Registry (v2.6.0+).
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) { if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
+1 -1
View File
@@ -36,7 +36,7 @@
"scripts": { "scripts": {
"test": "phpunit --configuration phpunit.xml", "test": "phpunit --configuration phpunit.xml",
"test:integration": "phpunit --configuration phpunit-integration.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": "phpstan analyse --no-progress",
"stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon" "stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon"
}, },
+35
View File
@@ -63,6 +63,41 @@ SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個
6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。 6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。 7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
## 前台與寫入鏈實測(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) ## 優化候選(待使用者確認後排入 backlog)
| # | 候選 | 針對 | 預期效果 | | # | 候選 | 針對 | 預期效果 |
+120
View File
@@ -0,0 +1,120 @@
# 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`。
## 設計(Release 2.2)
### 範圍收斂
- core 不再註冊 `post/wc_product`;WooCommerce AddOn 0.2.0 不再註冊 product / order / subscription 的 zone 欄位與 user `hot` 群組。WooCommerce 的 meta 只存在原生。既有表以 `bridge-restore-native post --group=wc_product --retire` 封存。
### 漂移偵測與修復(`TMDO_Drift_Check`)
- `wp tmdo drift-check <entity>` 以值比對扁平與原生(第一筆原生 row → 安全 unserialize → `Type_Caster::to_db`),逐 key 回報 miss / diff / extra,結果寫入 `wpdo_drift_status`。
- `--fix` 從原生重建漂移列,刪除已刪實體與非本 group post type 的列,不寫原生。
- **aeav_only 不適用**:那時扁平才是真實資料,原生本來就過期。`--fix` 拒絕、每日抽樣略過、Router 不因漂移退回原生。
- **Router**:上次檢查有漂移的 group 不路由,整個查詢維持原生;從未檢查的 group 照常路由(決策 2026-09-27:升級後不變慢)。
- **每日抽樣**:`wpdo_daily_health_check`,每 group 500 筆。
- **Hook Bus 關閉期間的寫入**:entity 從 disabled 進入 dual_write / shadow_read、或 Hook Bus flag 由關轉開時,60 秒後 `tmdo_drift_resync` 執行全量 `--fix`。不用 `post_modified_gmt` 增量,因為 WooCommerce 直接 SQL 寫 meta 不會更新它。
- **Auto-promoter** 以 `clean_since()`(2 天內乾淨的檢查)取代 shadow diff 數量;Promotion Guard 仍擋 HP / WC 站。
### 查詢
- 純排序搜尋(HivePress `_sort`、沒有路由篩選)在該 post type 每篇都有扁平列時改由扁平表驅動;Router 在 `wp_insert_post` 替新文章建空列,覆蓋率快取 15 分鐘。
## 後果
**好處**
- 資料正確性回到 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`(每日抽樣、Router 避開漂移中的 group)與重新啟用時的全量 `--fix` 處理;兩次抽樣之間的漂移仍可能短暫回傳過期結果,大量匯入後可手動執行 `drift-check <entity> --fix`。
- **decimal 欄位搭配 NUMERIC / DECIMAL**:原生先 CAST 成整數(`DECIMAL` 預設 (10,0)),扁平表精確比較,因此帶小數的值在區間邊界上可能不同。dev36 資料沒有這種值。
- **text 欄位超過 255 字元**存 NULL,這類值的篩選結果會與原生不同(有 `text_overflow` 記錄)。
- HivePress 搜尋 p50 為 321–866 ms(原生 600–1,713 ms),尚未達到 R-2.1 的 300 ms 目標(驗收:`notes/tmdo-acceptance-2.1-2026-09-26.md`)。價格篩選頁的 SQL 只剩約 75 ms、其餘為頁面渲染;純排序(如 `rating__desc`)必須 LEFT JOIN 保留沒有值的文章,無法 flat-first。
## 不在範圍內
- aeav_only 的空 key 讀取合併(B02):HP / WC 站不再使用 aeav_only,是否實作依其他站需求決定。
- Hot zone 的移除(ADR-003 的後續步驟)。
+27 -14
View File
@@ -40,7 +40,7 @@ class TMDO_Cache_Layer {
} }
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type ); $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; return;
} }
@@ -49,7 +49,7 @@ class TMDO_Cache_Layer {
// Check which IDs are already cached. // Check which IDs are already cached.
$uncached = array(); $uncached = array();
foreach ( $post_ids as $pid ) { foreach ( $post_ids as $pid ) {
$cached = wp_cache_get( "cold_{$pid}", $group ); $cached = wp_cache_get( TMDO_Zone_Cold::cache_key( (int) $pid, $post_type ), $group );
if ( false === $cached ) { if ( false === $cached ) {
$uncached[] = (int) $pid; $uncached[] = (int) $pid;
} }
@@ -94,7 +94,7 @@ class TMDO_Cache_Layer {
// Cache all results (including empty arrays for posts with no cold data). // Cache all results (including empty arrays for posts with no cold data).
foreach ( $uncached as $pid ) { foreach ( $uncached as $pid ) {
$data = $fetched[ $pid ] ?? array(); $data = $fetched[ $pid ] ?? array();
wp_cache_set( "cold_{$pid}", $data, $group, $ttl ); wp_cache_set( TMDO_Zone_Cold::cache_key( $pid, $post_type ), $data, $group, $ttl );
} }
} }
@@ -108,33 +108,46 @@ class TMDO_Cache_Layer {
*/ */
public static function warm_post( int $post_id, string $post_type ): void { public static function warm_post( int $post_id, string $post_type ): void {
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type ); $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; return;
} }
// Force a fresh read from DB (bypasses cache). // Force a fresh read from DB (bypasses cache).
$group = 'wpdo_cold_' . sanitize_key( $post_type ); $group = 'wpdo_cold_' . sanitize_key( $post_type );
wp_cache_delete( "cold_{$post_id}", $group ); wp_cache_delete( TMDO_Zone_Cold::cache_key( $post_id, $post_type ), $group );
// Re-read will populate cache. // Re-read will populate cache.
TMDO_Zone_Cold::get_blob( $post_id, $post_type ); 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. * Flush all cold cache for a post type.
* *
* Useful after bulk imports or schema changes. * Useful after bulk imports or schema changes. Bumps the post type's cache
* version (new keys from now on) instead of wp_cache_flush_group(), which
* Redis Object Cache runs as one server-side scan that blocks Redis on a
* large group; older entries expire by their TTL.
* *
* @param string $post_type Post type to flush. * @param string $post_type Post type to flush.
*/ */
public static function flush_group( string $post_type ): void { public static function flush_group( string $post_type ): void {
$group = 'wpdo_cold_' . sanitize_key( $post_type ); $versions = get_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, array() );
$versions = is_array( $versions ) ? $versions : array();
if ( function_exists( 'wp_cache_flush_group' ) ) { $versions[ $post_type ] = (int) ( $versions[ $post_type ] ?? 0 ) + 1;
wp_cache_flush_group( $group ); update_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, $versions, true );
}
// If wp_cache_flush_group is not available (older WP or no object cache),
// individual entries will expire via TTL.
} }
/** /**
@@ -170,7 +183,7 @@ class TMDO_Cache_Layer {
return array( return array(
'groups' => $groups, 'groups' => $groups,
'prefetch_support' => true, 'prefetch_support' => true,
'flush_support' => function_exists( 'wp_cache_flush_group' ), 'flush_support' => true,
); );
} }
+16
View File
@@ -261,6 +261,9 @@ class TMDO_Core {
$post_router = new TMDO_Post_Query_Router(); $post_router = new TMDO_Post_Query_Router();
$post_router->register_hooks(); $post_router->register_hooks();
} }
if ( class_exists( 'TMDO_Post_Query_Router' ) ) {
add_action( 'clean_post_cache', array( 'TMDO_Post_Query_Router', 'bump_count_version' ) );
}
// Zone B: Warm cleanup cron handler. // Zone B: Warm cleanup cron handler.
add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) ); add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) );
@@ -296,6 +299,19 @@ class TMDO_Core {
// v2.3.0 M6: daily health probe cron. // v2.3.0 M6: daily health probe cron.
add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) ); add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) );
// 1.2.0: daily flat-vs-native drift sample (read by the post query router).
add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );
// 1.2.0: rebuild flat from native after writes bypassed it (entity was
// disabled, or the Hook Bus was off).
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );
add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );
add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );
add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );
// 1.3.0: declared indexes a visitor request left for WP-Cron.
add_action( TMDO_Schema_Manager::INDEXES_HOOK, array( 'TMDO_Schema_Manager', 'ensure_pending_indexes' ) );
// v2.3.0 M6: daily snapshot prune cron (paired with health check). // v2.3.0 M6: daily snapshot prune cron (paired with health check).
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) ); add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
+62 -48
View File
@@ -217,6 +217,16 @@ class TMDO_Installer {
// Idempotent — only creates tables that don't already exist. // Idempotent — only creates tables that don't already exist.
self::install_v2_tables(); self::install_v2_tables();
// 1.4.2: archive sweeps before this release copied trashed meta daily.
// One unbounded DELETE: CLI only, never in a web activation.
if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( 'TMDO_Zone_Archive' ) ) {
global $wpdb;
$archive = TMDO_Zone_Archive::table();
if ( $archive === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $archive ) ) ) ) {
TMDO_Zone_Archive::remove_duplicates();
}
}
update_option( 'wpdo_db_version', self::SCHEMA_VERSION ); update_option( 'wpdo_db_version', self::SCHEMA_VERSION );
// Register wpdo_features with autoload=no so it doesn't inflate every page load. // Register wpdo_features with autoload=no so it doesn't inflate every page load.
@@ -771,29 +781,46 @@ class TMDO_Installer {
return $status; return $status;
} }
/**
* Every cron hook the plugin schedules. Cleared with wp_unschedule_hook(),
* which also removes events scheduled with args (tmdo_drift_resync( 'post' ),
* backfill batches) that wp_clear_scheduled_hook( $hook ) leaves behind
* (2.4.2 PR-138).
*/
private const CRON_HOOKS = array(
'wpdo_warm_cleanup',
'wpdo_archive_sweep',
'wpdo_errors_gc',
'wpdo_flush_views',
'wpdo_remove_uae_plugin_dir',
'wpdo_daily_health_check',
'wpdo_snapshot_prune_daily',
'wpdo_fsm_automator_run',
'wpdo_collect_site_metrics',
'wpdo_health_snapshot_monthly',
'wpdo_post_shadow_verify',
'wpdo_term_comment_shadow_verify',
'tmdo_drift_resync',
'tmdo_ensure_group_indexes',
'wpdo_entity_backfill_batch',
'wpdo_migration_tick',
'wpdo_auto_promote_check',
'wpdo_post_stress_test_batch',
'wpdo_stress_test_batch',
'wpdo_term_stress_test_batch',
'wpdo_comment_stress_test_batch',
);
/** /**
* Called on plugin deactivation. Non-destructive. * Called on plugin deactivation. Non-destructive.
*/ */
public static function deactivate(): void { public static function deactivate(): void {
wp_clear_scheduled_hook( 'wpdo_warm_cleanup' ); foreach ( self::CRON_HOOKS as $hook ) {
wp_clear_scheduled_hook( 'wpdo_archive_sweep' ); wp_unschedule_hook( $hook );
// v2.14.0: clear all plugin cron hooks (was incomplete pre-v2.14.0). }
wp_clear_scheduled_hook( 'wpdo_errors_gc' );
wp_clear_scheduled_hook( 'wpdo_flush_views' );
wp_clear_scheduled_hook( 'wpdo_remove_uae_plugin_dir' );
wp_clear_scheduled_hook( 'wpdo_daily_health_check' );
wp_clear_scheduled_hook( 'wpdo_snapshot_prune_daily' );
wp_clear_scheduled_hook( 'wpdo_fsm_automator_run' );
wp_clear_scheduled_hook( 'wpdo_collect_site_metrics' );
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_comment_stress_test_batch' );
} }
// ── v2.14.0: Multisite-aware site cleanup ───────────────────────────────── // ── v2.14.0: Multisite-aware site cleanup ─────────────────────────────────
/** /**
@@ -965,40 +992,26 @@ class TMDO_Installer {
// Sweep any remaining wpdo_* options (stragglers introduced post-v2.14.0 // Sweep any remaining wpdo_* options (stragglers introduced post-v2.14.0
// or by 3rd-party hooks). Validates name pattern before delete to avoid // or by 3rd-party hooks). Validates name pattern before delete to avoid
// accidental option removal. // accidental option removal.
$residual = $wpdb->get_col( // 1.4.2: tmdo_* options and their transients too (tmdo_schema_deferred,
$wpdb->prepare( // tmdo_entity_indexes, tmdo_cov_* …) — a reinstall saw stale ones.
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", // tmdo_latepoint_* belongs to the LatePoint add-on.
$wpdb->esc_like( 'wpdo_' ) . '%' foreach ( array( 'wpdo_', 'tmdo_', '_transient_tmdo_', '_transient_timeout_tmdo_' ) as $prefix ) {
) $residual = $wpdb->get_col(
); $wpdb->prepare(
foreach ( (array) $residual as $opt ) { "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
if ( preg_match( '/^wpdo_[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) { $wpdb->esc_like( $prefix ) . '%'
++$options_deleted; )
);
foreach ( (array) $residual as $opt ) {
if ( preg_match( '/^(_transient_(timeout_)?)?(wpdo|tmdo)_(?!latepoint_)[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
++$options_deleted;
}
} }
} }
// ── 5) Clear cron events ────────────────────────────────────────────── // ── 5) Clear cron events ──────────────────────────────────────────────
$crons = array( foreach ( self::CRON_HOOKS as $hook ) {
'wpdo_warm_cleanup', if ( wp_unschedule_hook( $hook ) ) {
'wpdo_archive_sweep',
'wpdo_errors_gc',
'wpdo_flush_views',
'wpdo_remove_uae_plugin_dir',
'wpdo_daily_health_check',
'wpdo_snapshot_prune_daily',
'wpdo_fsm_automator_run',
'wpdo_collect_site_metrics',
'wpdo_health_snapshot_monthly',
'wpdo_post_shadow_verify',
'wpdo_term_comment_shadow_verify',
'wpdo_post_stress_test_batch',
'wpdo_user_stress_test_batch',
'wpdo_term_stress_test_batch',
'wpdo_comment_stress_test_batch',
);
foreach ( $crons as $hook ) {
if ( false !== wp_next_scheduled( $hook ) ) {
wp_clear_scheduled_hook( $hook );
++$crons_cleared; ++$crons_cleared;
} }
} }
@@ -1436,7 +1449,8 @@ class TMDO_Installer {
original_meta_id bigint(20) unsigned NOT NULL DEFAULT 0, original_meta_id bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_post_id (post_id), KEY idx_post_id (post_id),
KEY idx_post_type_date (post_type, archived_at) KEY idx_post_type_date (post_type, archived_at),
KEY idx_original_meta (original_meta_id)
) {$charset};"; ) {$charset};";
foreach ( $sqls as $sql ) { foreach ( $sqls as $sql ) {
+24 -7
View File
@@ -48,7 +48,7 @@ class TMDO_Logger {
/** /**
* Log an INFO-level event (event-based signature, used by v2.0.0 engine code). * Log an INFO-level event (event-based signature, used by v2.0.0 engine code).
* *
* Writes only to PHP error_log; does NOT touch wpdo_errors (which is reserved * Writes only to PHP error_log, and only when debug logging is on; does NOT touch wpdo_errors (which is reserved
* for actual errors). Mode changes / cache flushes / audit prunes call here * for actual errors). Mode changes / cache flushes / audit prunes call here
* many times per request — keeping them out of the DB error table. * many times per request — keeping them out of the DB error table.
* *
@@ -57,19 +57,36 @@ class TMDO_Logger {
* @return void * @return void
*/ */
public static function info( string $event, array $context = array() ): void { public static function info( string $event, array $context = array() ): void {
// Routine events: only with WP_DEBUG_LOG (filter `tmdo_log_info`), or every
// CLI run and production request would flood the log.
if ( ! apply_filters( 'tmdo_log_info', defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) ) {
return;
}
self::write_info( $event, $context );
}
/**
* Write an INFO line to the PHP error log.
*
* @param string $event Event name.
* @param array<string, mixed> $context Structured context.
* @return void
*/
private static function write_info( string $event, array $context ): void {
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) ); error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
} }
/** /**
* Log a NOTICE-level event — alias of info() for callers that want a stronger * Log a NOTICE-level event: always written to the PHP error log (unlike
* level intent. Same routing (error_log only). * info()), for the audit trail of operator actions such as mode changes
* and snapshot deletions.
* *
* @param string $event Event name. * @param string $event Event name.
* @param array $context Structured context. * @param array<string, mixed> $context Structured context.
* @return void * @return void
*/ */
public static function notice( string $event, array $context = array() ): void { public static function notice( string $event, array $context = array() ): void {
self::info( $event, $context ); error_log( sprintf( '[WPDO][NOTICE][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
} }
/** /**
@@ -97,7 +114,7 @@ class TMDO_Logger {
*/ */
public static function debug( string $event, array $context = array() ): void { public static function debug( string $event, array $context = array() ): void {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
self::info( $event, $context ); self::write_info( $event, $context );
} }
} }
+1 -1
View File
@@ -55,7 +55,7 @@ final class TMDO_Post_Shadow_Verifier {
* is available. * is available.
* *
* @param string $post_type WP post_type to sample. * @param string $post_type WP post_type to sample.
* @param string $group_name Entity group name (e.g. 'wc_product'). * @param string $group_name Entity group name (e.g. 'hp_listing_core').
* @param string $flat_table Fully qualified flat table name. * @param string $flat_table Fully qualified flat table name.
* @param string[] $keys Meta keys to compare (each post checks all). * @param string[] $keys Meta keys to compare (each post checks all).
* @param int $sample_size Number of posts to sample (capped to DB count). * @param int $sample_size Number of posts to sample (capped to DB count).
@@ -872,7 +872,6 @@ final class TMDO_Post_Stress_Tester {
global $wpdb; global $wpdb;
$prefix = $wpdb->prefix . 'wpdo_post_'; $prefix = $wpdb->prefix . 'wpdo_post_';
$map = array( $map = array(
'product' => $prefix . 'wc_product',
'hp_listing' => $prefix . 'hp_listing_core', 'hp_listing' => $prefix . 'hp_listing_core',
'hp_request' => $prefix . 'hp_request_core', 'hp_request' => $prefix . 'hp_request_core',
'hp_vendor' => $prefix . 'hp_vendor_core', 'hp_vendor' => $prefix . 'hp_vendor_core',
@@ -1036,7 +1035,6 @@ final class TMDO_Post_Stress_Tester {
return array( return array(
$prefix . 'wp_core', $prefix . 'wp_core',
$prefix . 'attachment', $prefix . 'attachment',
$prefix . 'wc_product',
$prefix . 'hp_listing_core', $prefix . 'hp_listing_core',
$prefix . 'hp_request_core', $prefix . 'hp_request_core',
$prefix . 'hp_vendor_core', $prefix . 'hp_vendor_core',
+29 -10
View File
@@ -23,6 +23,9 @@ if ( ! defined( 'ABSPATH' ) ) {
*/ */
class TMDO_REST_API { class TMDO_REST_API {
/** Blocked views are counted 1 in this many (wpdo_rl_stats). */
private const RL_STATS_SAMPLE = 10;
const NAMESPACE = 'wpdo/v1'; const NAMESPACE = 'wpdo/v1';
/** /**
@@ -37,6 +40,8 @@ class TMDO_REST_API {
array( array(
'methods' => WP_REST_Server::READABLE, 'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_listings' ), 'callback' => array( $this, 'get_listings' ),
// Public by design: viewable post types and published posts only, fields
// declared show_in_rest only (1.1.1), per_page capped (wpdo_rest_max_per_page).
'permission_callback' => '__return_true', 'permission_callback' => '__return_true',
'args' => $this->listings_args(), 'args' => $this->listings_args(),
) )
@@ -48,6 +53,8 @@ class TMDO_REST_API {
array( array(
'methods' => WP_REST_Server::READABLE, 'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_listing' ), 'callback' => array( $this, 'get_listing' ),
// Public by design: the post must be viewable to the request (assert_post_viewable_or_error:
// publicly viewable or read_post); fields declared show_in_rest only (1.1.1).
'permission_callback' => '__return_true', 'permission_callback' => '__return_true',
'args' => array( 'args' => array(
'id' => array( 'id' => array(
@@ -65,6 +72,7 @@ class TMDO_REST_API {
array( array(
'methods' => WP_REST_Server::READABLE, 'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_stats' ), 'callback' => array( $this, 'get_stats' ),
// Public by design: view / engagement counters of a post viewable to the request.
'permission_callback' => '__return_true', 'permission_callback' => '__return_true',
'args' => array( 'args' => array(
'id' => array( 'id' => array(
@@ -82,6 +90,8 @@ class TMDO_REST_API {
array( array(
'methods' => WP_REST_Server::CREATABLE, 'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'post_view' ), 'callback' => array( $this, 'post_view' ),
// Public by design: anonymous visitors count views; viewable posts only, one view
// per IP per post per hour (rate limited).
'permission_callback' => '__return_true', 'permission_callback' => '__return_true',
'args' => array( 'args' => array(
'id' => array( 'id' => array(
@@ -718,14 +728,18 @@ class TMDO_REST_API {
if ( $cookie_blocked || $ip_blocked ) { if ( $cookie_blocked || $ip_blocked ) {
// Track rate-limit hits per post for admin visibility, capped at 100 entries. // Track rate-limit hits per post for admin visibility, capped at 100 entries.
$rl_stats = get_option( 'wpdo_rl_stats', array() ); // Sampled (1 in RL_STATS_SAMPLE, each sample adding RL_STATS_SAMPLE) so
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1; // anonymous requests cannot turn every blocked view into an option write.
if ( count( $rl_stats ) > 100 ) { if ( 1 === wp_rand( 1, self::RL_STATS_SAMPLE ) ) {
// Keep only the top-100 posts by hit count to bound option size. $rl_stats = get_option( 'wpdo_rl_stats', array() );
arsort( $rl_stats ); $rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + self::RL_STATS_SAMPLE;
$rl_stats = array_slice( $rl_stats, 0, 100, true ); if ( count( $rl_stats ) > 100 ) {
// Keep only the top-100 posts by hit count to bound option size.
arsort( $rl_stats );
$rl_stats = array_slice( $rl_stats, 0, 100, true );
}
update_option( 'wpdo_rl_stats', $rl_stats, false );
} }
update_option( 'wpdo_rl_stats', $rl_stats, false );
return new WP_REST_Response( return new WP_REST_Response(
array( array(
@@ -1396,8 +1410,13 @@ class TMDO_REST_API {
): WP_REST_Response { ): WP_REST_Response {
global $wpdb; 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 ); $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. // Build WHERE clauses from numeric filter params.
$where = array(); $where = array();
@@ -1443,8 +1462,8 @@ class TMDO_REST_API {
'post_type' => $post_type, 'post_type' => $post_type,
); );
unset( $row['post_id'], $row['updated_at'] ); unset( $row['post_id'], $row['updated_at'] );
// Keep only columns with show_in_rest => true (or all if no registry entry). // Keep only public columns (none when nothing is declared public).
$row = $visible_cols ? array_intersect_key( $row, $visible_cols ) : $row; $row = array_intersect_key( $row, $visible_cols );
return array_merge( $item, $row ); return array_merge( $item, $row );
}, },
$rows $rows
+25 -4
View File
@@ -103,7 +103,9 @@ class TMDO_Schema_Registry {
* - ttl (int) TTL in seconds (Zone B only, null = no expiry). * - ttl (int) TTL in seconds (Zone B only, null = no expiry).
* - cache_group (string) Object cache group (Zone C only). * - cache_group (string) Object cache group (Zone C only).
* - cache_ttl (int) Cache TTL in seconds (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 { public function register( string $provider, array $config ): void {
$config = wp_parse_args( $config = wp_parse_args(
@@ -120,7 +122,7 @@ class TMDO_Schema_Registry {
'cache_group' => '', 'cache_group' => '',
'cache_ttl' => HOUR_IN_SECONDS, 'cache_ttl' => HOUR_IN_SECONDS,
'provider' => $provider, 'provider' => $provider,
'show_in_rest' => true, 'show_in_rest' => false,
) )
); );
@@ -276,6 +278,25 @@ class TMDO_Schema_Registry {
return array_unique( $this->cold_fields[ $post_type ] ?? array() ); 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) or a contact / location
* detail (email, phone, address, IP), 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'] ?? '' ) )
// Contact and location details (2.3 PR-096): private even when declared public.
&& 1 !== preg_match( '/e_?mail|phone|address|(^|_)ip(_|$|\s)/i', $key . ' ' . ( $field['column'] ?? '' ) );
}
/** /**
* Returns hot column names for a post type where show_in_rest is true. * Returns hot column names for a post type where show_in_rest is true.
* Used by REST endpoints to restrict publicly exposed fields. * Used by REST endpoints to restrict publicly exposed fields.
@@ -286,7 +307,7 @@ class TMDO_Schema_Registry {
public function get_rest_visible_hot_columns( string $post_type ): array { public function get_rest_visible_hot_columns( string $post_type ): array {
$visible = array(); $visible = array();
foreach ( $this->fields as $field ) { 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']; $visible[] = $field['column'];
} }
} }
@@ -303,7 +324,7 @@ class TMDO_Schema_Registry {
public function get_rest_visible_cold_keys( string $post_type ): array { public function get_rest_visible_cold_keys( string $post_type ): array {
$visible = array(); $visible = array();
foreach ( $this->fields as $field ) { 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']; $visible[] = $field['meta_key'];
} }
} }
@@ -0,0 +1,533 @@
<?php
/**
* TMDO_Drift_Check — read-only flat-vs-native comparison for every entity type.
*
* For each backfillable group and key, the flat column is compared with what
* native meta would put there (first native row → safe unserialize →
* TMDO_Type_Caster::to_db()):
*
* miss — native holds a non-empty value, the flat column is NULL or has no row
* diff — both hold a value and the values differ (numbers, JSON and
* serialized values are compared by value, not by formatting)
* extra — the flat column holds a value, native has no row for the key
* (a required column holding its default counts as absent); rows of
* another post type or of a deleted entity count too
*
* run() writes nothing except the per-group outcome in the wpdo_drift_status
* option. fix() rebuilds the drifted rows from native (native is never written).
*
* @package WP_Data_Optimizer
* @since 1.2.0
*/
// phpcs:ignore WPDO.AntiEAV -- drift checker: must read raw meta to compare it with the flat tables
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Read-only checker: table and column names come from the registry / adapters, meta keys and ids go through prepare() or intval.
/**
* Flat-vs-native drift checker.
*/
final class TMDO_Drift_Check {
/**
* Option holding the last outcome per entity / group.
*/
public const OPTION = 'wpdo_drift_status';
/**
* Entities per group sampled by the daily check.
*/
public const CRON_SAMPLE = 500;
/**
* Single WP-Cron event rebuilding an entity's flat tables from native after
* writes bypassed them. Argument: the entity type.
*/
public const RESYNC_HOOK = 'tmdo_drift_resync';
/**
* Seconds before the scheduled rebuild, so the switch that triggered it
* finishes first.
*/
private const RESYNC_DELAY = 60;
/**
* MySQL regex for a plain decimal number, used to skip rows that obviously match.
* Exact matches compare BINARY: meta and flat tables may differ in collation,
* and a case-insensitive one would hide 'b' vs 'B'.
*/
private const SQL_NUMERIC = '^-?[0-9]+(\\\\.[0-9]+)?$';
/**
* Compare one entity type's flat tables with native meta.
*
* @param string $entity_type post | user | term | comment.
* @param string $group One group, or '' for every backfillable group.
* @param int $sample 0 checks every entity; N checks N random entities per group.
* @return array{entity:string, sample:int, groups:array<string, array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>}>, total:array{miss:int, diff:int, extra:int}}
* @throws InvalidArgumentException Unknown entity type or group.
*/
public static function run( string $entity_type, string $group = '', int $sample = 0 ): array {
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
$report = array(
'entity' => $entity_type,
'sample' => $sample,
'groups' => array(),
'total' => array(
'miss' => 0,
'diff' => 0,
'extra' => 0,
),
);
$status = get_option( self::OPTION, array() );
$status = is_array( $status ) ? $status : array();
foreach ( $groups as $name ) {
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
$result = self::check_group( $adapter, $entity_type, $name, $table, $sample );
unset( $result['ids'] );
$report['groups'][ $name ] = $result;
foreach ( array( 'miss', 'diff', 'extra' ) as $kind ) {
$report['total'][ $kind ] += $result[ $kind ];
}
$status[ $entity_type ][ $name ] = array(
'miss' => $result['miss'],
'diff' => $result['diff'],
'extra' => $result['extra'],
'sample' => $sample,
'checked_at' => gmdate( 'Y-m-d H:i:s' ),
);
}
update_option( self::OPTION, $status );
return $report;
}
/**
* Every backfillable group of the entity was checked within $max_age
* seconds (sampled or full) and none drifted. No record counts as unclean.
*
* @param string $entity_type Entity type.
* @param int $max_age Seconds.
*/
public static function clean_since( string $entity_type, int $max_age ): bool {
$status = get_option( self::OPTION, array() );
$status = is_array( $status ) ? ( $status[ $entity_type ] ?? array() ) : array();
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
if ( empty( $groups ) ) {
return false;
}
foreach ( $groups as $group ) {
$last = $status[ $group ] ?? null;
if ( ! is_array( $last )
|| (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0
|| (int) strtotime( ( $last['checked_at'] ?? '' ) . ' UTC' ) < time() - $max_age ) {
return false;
}
}
return true;
}
/**
* Daily check (wpdo_daily_health_check): sample every entity type written
* to both native and flat (dual_write, shadow_read). In aeav_only flat is
* the source of truth and native is stale by design, so drift means nothing
* there. The outcome replaces the recorded status, so a group found
* drifting leaves the router at once.
*/
public static function cron_tick(): void {
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) || ! TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
continue;
}
try {
self::run( $entity_type, '', self::CRON_SAMPLE );
} catch ( InvalidArgumentException $e ) {
continue; // No adapter for this entity type on this site.
}
}
}
/**
* Entities to rebuild after a mode change: one whose flat tables were not
* written (disabled) is written next to native again, and values written
* meanwhile are missing from flat.
*
* @param string $entity_type Entity type.
* @param string $new_mode New mode.
* @param string $old_mode Previous mode.
* @return string[]
*/
public static function resync_after_mode_change( string $entity_type, string $new_mode, string $old_mode ): array {
$keeps_native = array( TMDO_Mode_Manager::MODE_DUAL_WRITE, TMDO_Mode_Manager::MODE_SHADOW_READ );
return TMDO_Mode_Manager::MODE_DISABLED === $old_mode && in_array( $new_mode, $keeps_native, true ) ? array( $entity_type ) : array();
}
/**
* Entities to rebuild after the Hook Bus flag changes: no write reached
* flat while it was off. Every entity written to both native and flat
* (aeav_only keeps flat as the source of truth).
*
* @param mixed $old_value Previous flag value.
* @param mixed $value New flag value.
* @return string[]
*/
public static function resync_after_hook_bus_change( $old_value, $value ): array {
if ( self::flag_on( $old_value ) || ! self::flag_on( $value ) ) {
return array();
}
$entities = array();
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
$entities[] = $entity_type;
}
}
return $entities;
}
/**
* `wpdo_bridge_mode_changed` handler.
*
* @param string $entity_type Entity type.
* @param string $new_mode New mode.
* @param string $old_mode Previous mode.
*/
public static function on_mode_changed( string $entity_type, string $new_mode, string $old_mode ): void {
self::schedule_resync( self::resync_after_mode_change( $entity_type, $new_mode, $old_mode ) );
}
/**
* `update_option_wpdo_hook_bus_enabled` handler.
*
* @param mixed $old_value Previous flag value.
* @param mixed $value New flag value.
*/
public static function on_hook_bus_toggled( $old_value, $value ): void {
self::schedule_resync( self::resync_after_hook_bus_change( $old_value, $value ) );
}
/**
* `add_option_wpdo_hook_bus_enabled` handler: the flag is stored for the first time.
*
* @param string $option Option name.
* @param mixed $value Flag value.
*/
public static function on_hook_bus_added( $option, $value ): void {
unset( $option );
self::schedule_resync( self::resync_after_hook_bus_change( '0', $value ) );
}
/**
* RESYNC_HOOK handler. An entity that has meanwhile moved to aeav_only is
* left alone (fix() refuses).
*
* @param string $entity_type Entity type.
*/
public static function run_scheduled_fix( string $entity_type ): void {
try {
$result = self::fix( $entity_type );
TMDO_Schema_Manager::resynced( $entity_type ); // Groups upgraded while deferred use the flat table again.
TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] );
} catch ( InvalidArgumentException | RuntimeException $e ) {
TMDO_Logger::info(
'drift_resync_skipped',
array(
'entity' => $entity_type,
'reason' => $e->getMessage(),
)
);
}
}
/**
* Schedule one rebuild per entity type (not twice while one is pending).
*
* @param string[] $entities Entity types.
*/
private static function schedule_resync( array $entities ): void {
foreach ( $entities as $entity_type ) {
if ( ! wp_next_scheduled( self::RESYNC_HOOK, array( $entity_type ) ) ) {
wp_schedule_single_event( time() + self::RESYNC_DELAY, self::RESYNC_HOOK, array( $entity_type ) );
}
}
}
/**
* Whether a stored Hook Bus flag value means "on" (as TMDO_Hook_Bus_Bridge reads it).
*
* @param mixed $value Flag value.
*/
private static function flag_on( $value ): bool {
return '1' === (string) $value || true === $value;
}
/**
* Rebuild drifted flat rows from native meta, then check again.
*
* Rows of deleted entities (or, for post groups, of posts outside the
* group's post types) are deleted. Every other entity with drift in any key
* gets its whole row rebuilt from native; when native holds none of the
* group's keys the row is kept with its columns cleared. Row caches of the
* touched entities are invalidated. Native meta is never written.
*
* @param string $entity_type post | user | term | comment.
* @param string $group One group, or '' for every backfillable group.
* @return array{fixed:array{rebuilt:int, cleared:int, deleted_rows:int}, after:array<string, mixed>}
* @throws RuntimeException In aeav_only, where flat is the source of truth
* (and InvalidArgumentException via resolve() for an
* unknown entity type or group).
*/
public static function fix( string $entity_type, string $group = '' ): array {
global $wpdb;
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ) {
throw new RuntimeException( esc_html( $entity_type ) . ' is in aeav_only: flat is the source of truth and native meta is stale, so it cannot be rebuilt from native. Write flat back first (wp tmdo bridge-restore-native).' );
}
$id = $adapter->get_entity_id_column();
$fixed = array(
'rebuilt' => 0,
'cleared' => 0,
'deleted_rows' => 0,
);
foreach ( $groups as $name ) {
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
foreach ( self::stray_ids( $adapter, $entity_type, $name, $table ) as $eid ) {
$fixed['deleted_rows'] += (int) $wpdb->delete( $table, array( $id => $eid ), array( '%d' ) );
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
}
foreach ( self::check_group( $adapter, $entity_type, $name, $table, 0 )['ids'] as $eid ) {
if ( TMDO_Entity_Migration_Engine::migrate_entity( $entity_type, $name, $eid ) ) {
++$fixed['rebuilt'];
} else {
self::clear_row( $entity_type, $name, $table, $id, $eid );
++$fixed['cleared'];
}
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
}
}
return array(
'fixed' => $fixed,
'after' => self::run( $entity_type, $group ),
);
}
/**
* Adapter and group list for a run.
*
* @param string $entity_type Entity type.
* @param string $group One group, or '' for every backfillable group.
* @return array{0:TMDO_Entity_Adapter_Interface, 1:string[]}
* @throws InvalidArgumentException Unknown entity type or group.
*/
private static function resolve( string $entity_type, string $group ): array {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
throw new InvalidArgumentException( 'Unknown entity type: ' . esc_html( $entity_type ) );
}
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
if ( '' !== $group ) {
if ( ! in_array( $group, $groups, true ) ) {
throw new InvalidArgumentException( 'Unknown or orphan ' . esc_html( $entity_type ) . ' group: ' . esc_html( $group ) );
}
$groups = array( $group );
}
return array( $adapter, $groups );
}
/**
* Compare every key of one group.
*
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Group flat table.
* @param int $sample 0, or the number of random entities.
* @return array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>, ids:int[]}
*/
private static function check_group( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table, int $sample ): array {
global $wpdb;
$meta = $adapter->get_native_meta_table();
$id = $adapter->get_entity_id_column();
$mid = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
$scope = self::scope_sql( $adapter, $entity_type, $group, $sample );
$result = array(
'miss' => 0,
'diff' => 0,
'extra' => 0,
'keys' => array(),
'ids' => array(),
);
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
$key = (string) $field['key'];
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$type = $field['type'] ?? 'text';
// First native row per entity — what a single get_*_meta() read returns.
$native = $wpdb->prepare( "SELECT m.`{$id}` AS eid, m.meta_value AS v FROM `{$meta}` m JOIN ( SELECT `{$id}`, MIN(`{$mid}`) AS mid FROM `{$meta}` WHERE meta_key = %s GROUP BY `{$id}` ) x ON x.mid = m.`{$mid}` WHERE 1=1", $key )
. str_replace( '{id}', "m.`{$id}`", $scope );
// Text beyond the column width is stored as NULL by design (Type_Caster::fit_text).
$fits = 'text' === $type ? ' AND CHAR_LENGTH(n.v) <= ' . TMDO_Type_Caster::TEXT_MAX_CHARS : '';
$miss = array_map( 'intval', $wpdb->get_col( "SELECT n.eid FROM ( {$native} ) n LEFT JOIN `{$table}` f ON f.`{$id}` = n.eid WHERE LENGTH(n.v) > 0 AND f.`{$col}` IS NULL{$fits}" ) );
$candidates = $wpdb->get_results(
"SELECT n.eid, n.v, f.`{$col}` AS flat FROM ( {$native} ) n JOIN `{$table}` f ON f.`{$id}` = n.eid
WHERE f.`{$col}` IS NOT NULL AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY n.v
OR ( n.v REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6)) ) )",
ARRAY_A
);
$diff = array();
foreach ( (array) $candidates as $row ) {
if ( ! self::same( (string) $row['v'], (string) $row['flat'], $field ) ) {
$diff[] = (int) $row['eid'];
}
}
$absent = $wpdb->prepare( "NOT EXISTS ( SELECT 1 FROM `{$meta}` m WHERE m.`{$id}` = f.`{$id}` AND m.meta_key = %s )", $key );
if ( ! empty( $field['required'] ) ) {
$default = (string) TMDO_Type_Caster::to_db( null, $field );
$absent .= $wpdb->prepare(
" AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY %s OR ( %s REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(%s AS DECIMAL(30,6)) ) )",
$default,
$default,
$default
);
}
// Every flat row counts, including rows of other post types or deleted
// entities; only sample mode narrows the rows.
$extra = array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f WHERE f.`{$col}` IS NOT NULL AND {$absent}" . ( $sample > 0 ? str_replace( '{id}', "f.`{$id}`", $scope ) : '' ) ) );
$counts = array(
'miss' => count( $miss ),
'diff' => count( $diff ),
'extra' => count( $extra ),
);
$result['keys'][ $key ] = $counts;
$result['miss'] += $counts['miss'];
$result['diff'] += $counts['diff'];
$result['extra'] += $counts['extra'];
$result['ids'] = array_merge( $result['ids'], $miss, $diff, $extra );
}
$result['ids'] = array_values( array_unique( $result['ids'] ) );
return $result;
}
/**
* Flat rows whose entity no longer exists or, for post groups, whose post
* is outside the group's post types.
*
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Group flat table.
* @return int[]
*/
private static function stray_ids( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table ): array {
global $wpdb;
$id = $adapter->get_entity_id_column();
$primary = $adapter->get_primary_table();
$pk = $adapter->get_primary_id_column();
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
$type_sql = $post_types ? " AND p.post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')" : '';
return array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f LEFT JOIN `{$primary}` p ON p.`{$pk}` = f.`{$id}`{$type_sql} WHERE p.`{$pk}` IS NULL" ) );
}
/**
* Clear a row's columns: native holds none of the group's keys. The row is
* kept (NULL-only rows are not deleted, as the router may join on them);
* required columns go back to their default.
*
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Group flat table.
* @param string $id Entity id column.
* @param int $eid Entity id.
*/
private static function clear_row( string $entity_type, string $group, string $table, string $id, int $eid ): void {
global $wpdb;
$data = array();
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
$data[ TMDO_Schema_Manager::sanitize_column_name( (string) $field['key'] ) ] = TMDO_Type_Caster::to_db( null, $field );
}
$wpdb->update( $table, $data, array( $id => $eid ) );
}
/**
* SQL restricting the checked entities: the group's post types, and in
* sample mode a random set of entity ids. `{id}` stands for the id column.
*
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param int $sample 0, or the number of random entities.
*/
private static function scope_sql( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, int $sample ): string {
global $wpdb;
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
$type_sql = $post_types
? " post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')"
: '';
if ( $sample <= 0 ) {
return $type_sql ? " AND {id} IN ( SELECT ID FROM {$wpdb->posts} WHERE{$type_sql} )" : '';
}
$primary = $adapter->get_primary_table();
$pk = $adapter->get_primary_id_column();
$ids = $wpdb->get_col( "SELECT `{$pk}` FROM `{$primary}`" . ( $type_sql ? " WHERE{$type_sql}" : '' ) . ' ORDER BY RAND() LIMIT ' . (int) $sample );
return ' AND {id} IN (' . ( $ids ? implode( ',', array_map( 'intval', $ids ) ) : '0' ) . ')';
}
/**
* Whether a flat value is what native meta would store.
*
* @param string $native Raw native meta_value.
* @param string $flat Flat column value.
* @param array<string, mixed> $field Registered field definition.
*/
private static function same( string $native, string $flat, array $field ): bool {
$type = $field['type'] ?? 'text';
$value = TMDO_Safe_Unserialize::run( $native );
if ( 'text' === $type && is_scalar( $value ) && mb_strlen( (string) $value ) > TMDO_Type_Caster::TEXT_MAX_CHARS ) {
return false; // Belongs in flat as NULL, yet this row holds a value.
}
$expected = TMDO_Type_Caster::to_db( $value, $field );
if ( null === $expected ) {
return false;
}
if ( 'json' === $type ) {
// Key order and int-vs-float formatting are not drift.
return json_decode( $flat, true ) == json_decode( (string) $expected, true ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
}
if ( is_numeric( $expected ) && is_numeric( $flat ) ) {
return abs( (float) $expected - (float) $flat ) < 0.000001;
}
return (string) $expected === $flat;
}
}
@@ -104,7 +104,7 @@ class TMDO_Health_Cron {
} }
} catch ( Throwable $e ) { } catch ( Throwable $e ) {
// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check. // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- detector failure must not break health check.
error_log( '[WPDO] Module detector exception in health cron: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log TMDO_Logger::error( 'health_cron', 'module_detector', $e->getMessage() );
} }
} }
+11 -8
View File
@@ -6,7 +6,8 @@
* 判斷條件(皆滿足才觸發): * 判斷條件(皆滿足才觸發):
* 1. 當前 mode = shadow_read * 1. 當前 mode = shadow_read
* 2. 進入 shadow_read 距今 ≥ `min_days` * 2. 進入 shadow_read 距今 ≥ `min_days`
* 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0 * 3. 該 entity 每個群組最近一次 drift-check(2 天內,抽樣或全量)皆無漂移
* (1.2.0 起取代 shadow_diffs 筆數;無紀錄不升級)
* *
* 這符合「穩定期沒問題就進 production」的安全遷移原則。 * 這符合「穩定期沒問題就進 production」的安全遷移原則。
* 若 diff 出現,規則永不觸發;人工介入即可。 * 若 diff 出現,規則永不觸發;人工介入即可。
@@ -35,6 +36,9 @@ final class TMDO_Auto_Promoter {
public const CRON_HOOK = 'wpdo_auto_promote_check'; public const CRON_HOOK = 'wpdo_auto_promote_check';
/** A drift check older than this (seconds) does not count for promotion. */
public const DRIFT_MAX_AGE = 2 * DAY_IN_SECONDS;
public static function init(): void { public static function init(): void {
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) ); add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 ); add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
@@ -82,7 +86,7 @@ final class TMDO_Auto_Promoter {
/** /**
* 評估單一 entity 是否可升級。 * 評估單一 entity 是否可升級。
* *
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'has_diff' | 'vetoed' * @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'drift_not_clean' | 'vetoed' | 'error'
*/ */
public static function check_entity( string $type, int $min_days, array $entered_all ): string { public static function check_entity( string $type, int $min_days, array $entered_all ): string {
$current = TMDO_Mode_Manager::get( $type ); $current = TMDO_Mode_Manager::get( $type );
@@ -95,14 +99,13 @@ final class TMDO_Auto_Promoter {
return 'not_ready'; return 'not_ready';
} }
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity(); if ( ! TMDO_Drift_Check::clean_since( $type, self::DRIFT_MAX_AGE ) ) {
$count = (int) ( $diff_counts[ $type ] ?? 0 ); return 'drift_not_clean';
if ( $count > 0 ) {
return 'has_diff';
} }
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間) // 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)。第 4 參數原為
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count ); // shadow diff 筆數;1.2.0 起改看 drift,固定傳 0。
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, 0 );
if ( ! $should ) { if ( ! $should ) {
return 'vetoed'; return 'vetoed';
} }
+14 -1
View File
@@ -325,7 +325,20 @@ final class TMDO_Entity_Health {
return TMDO_Mode_Manager::ALL_MODES[ $idx - 1 ]; return TMDO_Mode_Manager::ALL_MODES[ $idx - 1 ];
} }
/**
* Whether a backfill batch is scheduled for any group of the entity. Batches
* are scheduled with (entity, group) args, so looking for (entity) alone
* never found one (2.4.2 PR-137).
*
* @param string $entity_type Entity type.
* @return bool
*/
private static function is_backfill_active( string $entity_type ): bool { private static function is_backfill_active( string $entity_type ): bool {
return (bool) wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type ) ); foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
if ( wp_next_scheduled( 'wpdo_entity_backfill_batch', array( $entity_type, $group ) ) ) {
return true;
}
}
return false;
} }
} }
@@ -144,13 +144,14 @@ final class TMDO_Entity_Migration_Engine {
} }
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {
++$stats['errors']; ++$stats['errors'];
error_log( TMDO_Logger::error(
sprintf( 'migration_engine',
'[UAE Migration] Error %s/%s ID=%d: %s', 'migrate_group',
$entity_type, $e->getMessage(),
$group_name, array(
$entity_id, 'entity_type' => $entity_type,
$e->getMessage() 'group' => $group_name,
'entity_id' => $entity_id,
) )
); );
} }
@@ -175,6 +176,9 @@ final class TMDO_Entity_Migration_Engine {
// 標記完成 // 標記完成
if ( ! $options['dry_run'] ) { if ( ! $options['dry_run'] ) {
self::mark_migration_completed( $entity_type, $group_name ); self::mark_migration_completed( $entity_type, $group_name );
if ( $stats['migrated'] > 0 ) {
self::counts_changed( $entity_type );
}
} }
$stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 ); $stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 );
@@ -182,6 +186,45 @@ final class TMDO_Entity_Migration_Engine {
return $stats; return $stats;
} }
/**
* Rows rebuilt outside the Hook Bus (backfill, drift-check --fix): routed
* post counts cached for the old rows are stale (2.4 PR-129).
*
* @param string $entity_type Entity type.
* @return void
*/
private static function counts_changed( string $entity_type ): void {
if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Query_Router' ) ) {
TMDO_Post_Query_Router::bump_count_version();
}
}
/**
* Rebuild one entity's row of a group from native meta.
*
* @return bool False when native holds none of the group's keys (nothing written).
*/
public static function migrate_entity( string $entity_type, string $group_name, int $entity_id ): bool {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
$group_fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
if ( ! $adapter || empty( $group_fields ) ) {
return false;
}
$migrated = self::migrate_single_entity(
$entity_type,
$group_name,
$entity_id,
array_column( $group_fields, 'key' ),
array_column( $group_fields, null, 'key' ),
$adapter,
false
);
if ( $migrated ) {
self::counts_changed( $entity_type );
}
return $migrated;
}
/** /**
* 遷移單一 entity 的所有 meta * 遷移單一 entity 的所有 meta
*/ */
@@ -243,6 +286,27 @@ final class TMDO_Entity_Migration_Engine {
return false; return false;
} }
// REPLACE rewrites the whole row: a column left out falls back to its DDL
// default (e.g. session_count DEFAULT 0), inventing a value the object never
// had. Keys without a native row are written as NULL; required columns are
// NOT NULL, so they keep their default (treated as "absent" by the diff logger).
foreach ( $field_map as $key => $field_def ) {
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
if ( ! array_key_exists( $col, $data ) && empty( $field_def['required'] ) ) {
$data[ $col ] = null;
$formats[] = '%s';
}
}
// REPLACE drops columns it is not given: keep a post row's status/date
// copies, or one NULL row turns the copy route off for the group (2.4 PR-127).
if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Status_Sync' ) ) {
foreach ( TMDO_Post_Status_Sync::insert_columns( $entity_id, $group_name ) as $col => $value ) {
$data[ $col ] = $value;
$formats[] = '%s';
}
}
if ( $dry_run ) { if ( $dry_run ) {
return true; return true;
} }
@@ -349,13 +413,25 @@ final class TMDO_Entity_Migration_Engine {
++$batch_migrated; ++$batch_migrated;
} }
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {
error_log( sprintf( '[UAE Migration Batch] Error %s/%s ID=%d: %s', $entity_type, $group_name, $entity_id, $e->getMessage() ) ); TMDO_Logger::error(
'migration_engine',
'migrate_group_batch',
$e->getMessage(),
array(
'entity_type' => $entity_type,
'group' => $group_name,
'entity_id' => $entity_id,
)
);
} }
$last_id = $entity_id; $last_id = $entity_id;
} }
$total = $prev_total + $batch_migrated; $total = $prev_total + $batch_migrated;
$done = count( $entity_ids ) < $batch_size; $done = count( $entity_ids ) < $batch_size;
if ( $batch_migrated > 0 ) {
self::counts_changed( $entity_type );
}
if ( $done ) { if ( $done ) {
self::mark_migration_completed( $entity_type, $group_name ); self::mark_migration_completed( $entity_type, $group_name );
@@ -374,6 +450,23 @@ final class TMDO_Entity_Migration_Engine {
); );
} }
/**
* Registered groups of an entity type that can be backfilled from native
* meta. Orphan groups restored from wpdo_registry_meta are left out: their
* values live only in the flat table, a backfill would wipe them.
*
* @return string[]
*/
public static function backfillable_groups( string $entity_type ): array {
$orphans = array();
foreach ( TMDO_Entity_Registry::get_restored_groups() as $restored ) {
if ( $entity_type === $restored['entity_type'] ) {
$orphans[] = $restored['group'];
}
}
return array_values( array_diff( TMDO_Entity_Registry::get_groups_for_type( $entity_type ), $orphans ) );
}
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// 斷點管理 // 斷點管理
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
@@ -159,9 +159,12 @@ final class TMDO_Entity_Registry {
self::$scopes[ $entity_type ][ $group_name ] = $post_types; self::$scopes[ $entity_type ][ $group_name ] = $post_types;
} }
$keys = array_column( $normalized, 'key' ); $keys = array_column( $normalized, 'key' );
// A sort index may end with the entity ID (flat-first tie-break, 2.4 PR-118).
$id_col = (string) self::$adapters[ $entity_type ]->get_entity_id_column();
foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) { foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) {
$columns = array_values( array_map( 'strval', (array) $columns ) ); $columns = array_values( array_map( 'strval', (array) $columns ) );
if ( $columns && ! array_diff( $columns, $keys ) ) { $fields = '' !== $id_col && end( $columns ) === $id_col ? array_slice( $columns, 0, -1 ) : $columns;
if ( $fields && ! array_diff( $fields, $keys ) ) {
self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns; self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns;
} }
} }
+365 -51
View File
@@ -44,6 +44,15 @@ final class TMDO_Hook_Bus {
// ── 刪除攔截 ────────────────────────────────────────── // ── 刪除攔截 ──────────────────────────────────────────
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 ); 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( add_action(
$adapter->get_delete_hook(), $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 ) { public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
// 已在短路中 → 避免遞迴 $type = self::resolve_type_from_current_filter();
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) { if ( ! $type ) {
return $check; return $check;
} }
$type = self::resolve_type_from_current_filter(); // 已在短路中 → 避免遞迴
if ( ! $type ) { if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
return $check; return $check;
} }
@@ -86,10 +95,10 @@ final class TMDO_Hook_Bus {
} }
// ── Mode-aware dispatch ────────────────────────────── // ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全放行給 WP 原生 meta(回 null / $check) // disabled : 完全放行給 WP 原生 meta(回 null / $check)
// dual_write: 寫 flat,然後 return null 讓 WP 繼續寫 EAV // dual_write : 放行 WP 寫 EAV;updated_/added_ action 再從原生同步 flat
// shadow_read: 寫 flat,然後 return null 讓 WP 繼續寫 EAV // shadow_read: 同 dual_write
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV) // aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) { if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
return $check; // disabled return $check; // disabled
} }
@@ -105,6 +114,13 @@ final class TMDO_Hook_Bus {
return true; 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 等訂閱者需要變更前的值。 // 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。 // 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' ); $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 ); 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 return $flat_result; // aeav_only
} }
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) { public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
// 已在短路中 → 避免遞迴 $type = self::resolve_type_from_current_filter();
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) { if ( ! $type ) {
return $check; return $check;
} }
$type = self::resolve_type_from_current_filter(); // 已在短路中 → 避免遞迴
if ( ! $type ) { if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
return $check; return $check;
} }
@@ -150,6 +161,12 @@ final class TMDO_Hook_Bus {
return true; 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)。 // UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。 // 截取 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' ); $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 ); 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 $flat_result;
return null; }
// ─────────────────────────────────────────────────────────
// 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;
} }
return $flat_result; $before = self::maybe_read_before_value( $type, $object_id, $meta_key, $field_def, $op );
$native = self::native_first_value( $type, $object_id, $meta_key );
$result = null === $native
? self::clear_column( $type, $object_id, $meta_key, $field_def )
: self::perform_upsert( $type, $object_id, $meta_key, maybe_unserialize( $native ), $field_def );
if ( 'delete' === $op ) {
do_action( 'wpdo_after_delete', $type, $object_id, $meta_key, $meta_value, $result, $delete_all, $before );
} else {
do_action( 'wpdo_after_write', $type, $object_id, $meta_key, $meta_value, $result, $op, $before );
}
}
/**
* `added_{type}_meta` action.
*
* @param int $meta_id Meta ID.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function on_meta_added( $meta_id, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( $type ) {
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'add' );
}
}
/**
* `updated_{type}_meta` action; also re-syncs the old key of a row renamed
* through update_metadata_by_mid.
*
* @param int $meta_id Meta ID.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function on_meta_updated( $meta_id, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( ! $type ) {
return;
}
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'update' );
$rename_key = $type . ':' . (int) $meta_id;
if ( isset( self::$pending_renames[ $rename_key ] ) ) {
$old = self::$pending_renames[ $rename_key ];
unset( self::$pending_renames[ $rename_key ] );
self::sync_from_native( $type, $old['id'], $old['key'], null, 'delete' );
}
}
/**
* `update_{type}_metadata_by_mid` filter: remember the old key when a row
* is renamed, so its flat column can follow. Never changes $check.
*
* @param mixed $check Short-circuit value.
* @param int $meta_id Meta ID.
* @param mixed $meta_value New value.
* @param string|false $meta_key New key, or false to keep it.
* @return mixed
*/
public static function before_update_by_mid( $check, $meta_id, $meta_value, $meta_key ) {
$type = self::resolve_type_from_meta_hook();
if ( ! $type || false === $meta_key ) {
return $check;
}
$row = self::native_row_by_mid( $type, (int) $meta_id );
if ( $row && $row['key'] !== $meta_key && TMDO_Entity_Registry::get_field( $type, $row['key'] ) ) {
self::$pending_renames[ $type . ':' . (int) $meta_id ] = array(
'type' => $type,
'id' => $row['id'],
'key' => $row['key'],
);
}
return $check;
}
/**
* `delete_{type}_meta` action (rows still exist): record which entities a
* delete touches — a delete_all passes object ID 0.
*
* @param int[] $meta_ids Meta IDs to delete.
* @param int $object_id Entity ID (0 for delete_all).
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function before_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( ! $type || ! TMDO_Entity_Registry::get_field( $type, (string) $meta_key ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return;
}
$ids = array();
foreach ( (array) $meta_ids as $mid ) {
$row = self::native_row_by_mid( $type, (int) $mid );
if ( $row ) {
$ids[ $row['id'] ] = $row['id'];
}
}
self::$pending_deletes[ $type . ':' . $meta_key ] = array_values( $ids );
}
/**
* `deleted_{type}_meta` action: re-sync every entity the delete touched.
*
* @param int[] $meta_ids Deleted meta IDs.
* @param int $object_id Entity ID (0 for delete_all).
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function on_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( ! $type ) {
return;
}
$pending_key = $type . ':' . $meta_key;
$ids = self::$pending_deletes[ $pending_key ] ?? array( (int) $object_id );
unset( self::$pending_deletes[ $pending_key ] );
foreach ( $ids as $id ) {
if ( $id > 0 ) {
self::sync_from_native( $type, (int) $id, (string) $meta_key, $meta_value, 'delete', 0 === (int) $object_id );
}
}
}
/**
* First native value of a key (the one get_*_meta( $id, $key, true ) returns),
* read with SQL so no meta filter runs. Null when the entity has no row.
*
* @param string $type Entity type.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @return string|null
*/
private static function native_first_value( string $type, int $object_id, string $meta_key ): ?string {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
// get_results(): get_var() turns an empty string into null.
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$pk}` LIMIT 1", $object_id, $meta_key ),
ARRAY_A
);
return $rows ? (string) $rows[0]['meta_value'] : null;
}
/**
* Entity ID and key of a native meta row.
*
* @param string $type Entity type.
* @param int $meta_id Meta ID.
* @return array{id:int, key:string}|null
*/
private static function native_row_by_mid( string $type, int $meta_id ): ?array {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
$row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key AS mkey FROM `{$table}` WHERE `{$pk}` = %d", $meta_id ), ARRAY_A );
if ( ! $row ) {
return null;
}
return array(
'id' => (int) $row['id'],
'key' => (string) $row['mkey'],
);
} }
/** /**
@@ -291,6 +525,34 @@ final class TMDO_Hook_Bus {
return 'flat'; 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 操作 * 執行 Upsert 操作
*/ */
@@ -322,8 +584,8 @@ final class TMDO_Hook_Bus {
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def ); $db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] ); $format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
// 鎖防遞迴 // 鎖防遞迴(含 entity type:user 5 與 post 5 不可互鎖)
$lock_key = $entity_id . ':' . $meta_key; $lock_key = $type . ':' . $entity_id . ':' . $meta_key;
self::$internal_ops[ $lock_key ] = true; self::$internal_ops[ $lock_key ] = true;
try { try {
@@ -388,12 +650,20 @@ final class TMDO_Hook_Bus {
// ── Mode-aware dispatch ────────────────────────────── // ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV // disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check // dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff // shadow_read : 讀取仍走 EAV,抽樣與 flat 比對記錄 diff
// aeav_only : 讀取走 UAE flat,不讀 EAV // aeav_only : 讀取走 UAE flat,不讀 EAV
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) { if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
return $check; // disabled / dual_write 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']; $group = $field_def['group'];
$row = self::get_or_load_row( $type, (int) $object_id, $group ); $row = self::get_or_load_row( $type, (int) $object_id, $group );
@@ -401,22 +671,6 @@ final class TMDO_Hook_Bus {
$has_value = is_array( $row ) && array_key_exists( $col, $row ); $has_value = is_array( $row ) && array_key_exists( $col, $row );
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null; $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 原生慣例 // 值為空 → 回 WP 原生慣例
if ( empty( $row ) || $value === null || $value === '' ) { if ( empty( $row ) || $value === null || $value === '' ) {
return $single ? '' : array(); return $single ? '' : array();
@@ -426,6 +680,36 @@ final class TMDO_Hook_Bus {
return $single ? array( $value ) : array( $value ); 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() );
}
}
/** /**
* 取得(或載入)完整列資料,並快取 * 取得(或載入)完整列資料,並快取
*/ */
@@ -495,6 +779,12 @@ final class TMDO_Hook_Bus {
return true; 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(v1.3.1)
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' ); $before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' );
@@ -579,12 +869,7 @@ final class TMDO_Hook_Bus {
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value ); do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta return $result !== false; // aeav_only
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
return $result !== false;
} }
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
@@ -627,6 +912,7 @@ final class TMDO_Hook_Bus {
private static function touch_posts_last_changed( string $type ): void { private static function touch_posts_last_changed( string $type ): void {
if ( 'post' === $type ) { if ( 'post' === $type ) {
wp_cache_set_posts_last_changed(); wp_cache_set_posts_last_changed();
TMDO_Post_Query_Router::bump_count_version();
} }
} }
@@ -644,6 +930,17 @@ final class TMDO_Hook_Bus {
return null; 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 meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。
* *
@@ -652,10 +949,23 @@ final class TMDO_Hook_Bus {
*/ */
private static function field_for( string $type, int $object_id, string $meta_key ): ?array { private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key ); $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( null === $field_def || 'post' !== $type ) { if ( null !== $field_def && 'post' === $type ) {
return $field_def; $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
} }
return TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) ); // A group whose table upgrade waits for WP-CLI / WP-Cron stays native
// (not in aeav_only, where native is stale; the router agrees).
return null !== $field_def && self::deferred( $type, (string) $field_def['group'] ) ? null : $field_def;
}
/**
* Whether a group is deferred and so read and written natively.
*
* @param string $type Entity type.
* @param string $group Group name.
* @return bool
*/
private static function deferred( string $type, string $group ): bool {
return TMDO_Schema_Manager::is_deferred( $type, $group ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $type );
} }
/** /**
@@ -673,10 +983,13 @@ final class TMDO_Hook_Bus {
// 回傳所有群組的所有欄位 // 回傳所有群組的所有欄位
$result = array(); $result = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) { foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
$row = self::get_or_load_row( $type, $entity_id, $group ); $deferred = self::deferred( $type, $group );
$row = $deferred ? array() : self::get_or_load_row( $type, $entity_id, $group );
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) { foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] ); $col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
$result[ $field['key'] ] = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field ); $result[ $field['key'] ] = $deferred
? get_metadata( $type, $entity_id, $field['key'], true )
: TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
} }
} }
return $result; return $result;
@@ -684,7 +997,8 @@ final class TMDO_Hook_Bus {
$field_def = self::field_for( $type, $entity_id, $key ); $field_def = self::field_for( $type, $entity_id, $key );
if ( ! $field_def ) { if ( ! $field_def ) {
return null; // A deferred group's value is the native one.
return null !== TMDO_Entity_Registry::get_field( $type, $key ) ? get_metadata( $type, $entity_id, $key, true ) : null;
} }
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] ); $row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
+6 -1
View File
@@ -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 = self::all();
$all[ $entity_type ] = $new_mode; $all[ $entity_type ] = $new_mode;
@@ -154,7 +159,7 @@ final class TMDO_Mode_Manager {
$entered[ $entity_type ] = time(); $entered[ $entity_type ] = time();
update_option( self::OPT_ENTERED_AT, $entered, false ); update_option( self::OPT_ENTERED_AT, $entered, false );
TMDO_Logger::info( TMDO_Logger::notice(
'bridge_mode_changed', 'bridge_mode_changed',
array( array(
'entity_type' => $entity_type, 'entity_type' => $entity_type,
@@ -29,7 +29,10 @@ final class TMDO_Post_Status_Sync {
public const OPTION = 'tmdo_post_status_columns'; public const OPTION = 'tmdo_post_status_columns';
private const INDEX = 'idx_post_status_date'; private const INDEX = 'idx_post_status_date_id';
/** The 2.2–2.3 (post_status, post_date) index, replaced by INDEX. */
private const LEGACY_INDEX = 'idx_post_status_date';
/** /**
* Hook the sync into WordPress. * Hook the sync into WordPress.
@@ -65,6 +68,10 @@ final class TMDO_Post_Status_Sync {
unset( $new_status, $old_status ); unset( $new_status, $old_status );
foreach ( self::groups_with_columns() as $group ) { foreach ( self::groups_with_columns() as $group ) {
$table = TMDO_Schema_Manager::get_table_name( 'post', $group ); $table = TMDO_Schema_Manager::get_table_name( 'post', $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
self::forget( $group ); // The table was dropped (retired or uninstalled group).
continue;
}
$wpdb->query( $wpdb->query(
$wpdb->prepare( $wpdb->prepare(
"UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name. "UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
@@ -156,6 +163,10 @@ final class TMDO_Post_Status_Sync {
} }
self::set_state( $group, 'ready' ); self::set_state( $group, 'ready' );
} }
// Routed counts cached while the copies were stale must go.
if ( ! $dry_run && class_exists( 'TMDO_Post_Query_Router' ) ) {
TMDO_Post_Query_Router::bump_count_version();
}
return $report; return $report;
} }
@@ -199,7 +210,12 @@ final class TMDO_Post_Status_Sync {
$changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL'; $changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL';
} }
if ( ! in_array( self::INDEX, $indexes, true ) ) { if ( ! in_array( self::INDEX, $indexes, true ) ) {
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`)'; // Ends with post_id: the router reads the date route's ID tie-break
// from it, so MySQL walks the index and stops at the LIMIT (2.4 PR-128).
$changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`, `post_id`)';
}
if ( in_array( self::LEGACY_INDEX, $indexes, true ) ) {
$changes[] = 'DROP INDEX `' . self::LEGACY_INDEX . '`';
} }
if ( $changes && false === $wpdb->query( "ALTER TABLE `{$table}` " . implode( ', ', $changes ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fixed DDL on a registry table name. 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; return false;
@@ -220,6 +236,20 @@ final class TMDO_Post_Status_Sync {
return is_array( $states ) ? array_keys( $states ) : array(); return is_array( $states ) ? array_keys( $states ) : array();
} }
/**
* Drop a group from the recorded states.
*
* @param string $group Group name.
* @return void
*/
private static function forget( string $group ): void {
$states = get_option( self::OPTION, array() );
if ( is_array( $states ) && isset( $states[ $group ] ) ) {
unset( $states[ $group ] );
update_option( self::OPTION, $states, true );
}
}
/** /**
* Record a group's state. * Record a group's state.
* *
@@ -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
)
);
}
}
+323 -13
View File
@@ -23,6 +23,12 @@ final class TMDO_Schema_Manager {
/** Option: "entity/group" → md5 of the composite index definition already applied. */ /** Option: "entity/group" → md5 of the composite index definition already applied. */
public const INDEXES_OPTION = 'tmdo_entity_indexes'; public const INDEXES_OPTION = 'tmdo_entity_indexes';
/** Single cron event that adds declared indexes a visitor request had to leave out. */
public const INDEXES_HOOK = 'tmdo_ensure_group_indexes';
/** Option: "entity/group" → time an existing table's upgrade was left to WP-CLI / WP-Cron. */
public const DEFERRED_OPTION = 'tmdo_schema_deferred';
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */ /** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
private static array $table_exists_cache = array(); private static array $table_exists_cache = array();
@@ -77,13 +83,13 @@ final class TMDO_Schema_Manager {
string $group_name, string $group_name,
array $field_definitions array $field_definitions
): bool { ): bool {
global $wpdb;
// Schema 版本比對:若未變動則跳過 // Schema 版本比對:若未變動則跳過
$schema_hash = self::calculate_schema_hash( $field_definitions ); $schema_hash = self::calculate_schema_hash( $field_definitions );
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name ); $stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
if ( $stored_hash === $schema_hash ) { if ( $stored_hash === $schema_hash ) {
// Upgraded meanwhile, or the definition went back to the stored one.
self::end_deferral( $entity_type, $group_name );
return true; return true;
} }
@@ -92,7 +98,55 @@ final class TMDO_Schema_Manager {
return false; return false;
} }
$table = self::get_table_name( $entity_type, $group_name ); $table = self::get_table_name( $entity_type, $group_name );
if ( self::upgrade_waits( $entity_type, $table ) ) {
self::defer_upgrade( "{$entity_type}/{$group_name}" );
return false;
}
if ( ! self::table_exists( $table ) ) {
if ( ! self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ) ) {
return false;
}
// A new table starts empty while native meta may already hold the
// group's values: native until the drift fix has filled it in.
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type ) ) {
self::record_deferred( "{$entity_type}/{$group_name}" );
self::end_deferral( $entity_type, $group_name );
}
return true;
}
// An existing table is altered by one process at a time (anonymous
// /wp-cron.php requests also pass ddl_allowed()); a process that waited
// finds the upgrade done.
if ( ! self::lock() ) {
return false;
}
try {
if ( self::get_stored_schema_hash( $entity_type, $group_name ) === $schema_hash ) {
return true;
}
return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash );
} finally {
self::unlock();
}
}
/**
* Create or alter the table (dbDelta) and record its definition.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @param array<int, array<string, mixed>> $field_definitions Normalized fields.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $table Table name.
* @param string $schema_hash Definition hash.
* @return bool
*/
private static function write_table( string $entity_type, string $group_name, array $field_definitions, TMDO_Entity_Adapter_Interface $adapter, string $table, string $schema_hash ): bool {
global $wpdb;
$existed = self::table_exists( $table );
$charset = $wpdb->get_charset_collate(); $charset = $wpdb->get_charset_collate();
$id_col = $adapter->get_entity_id_column(); $id_col = $adapter->get_entity_id_column();
@@ -144,6 +198,11 @@ final class TMDO_Schema_Manager {
} }
} }
// A new table gets its declared composite indexes while it is empty.
foreach ( TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name ) as $name => $keys ) {
$sql_indexes[] = 'KEY `cidx_' . self::sanitize_column_name( (string) $name ) . '` (' . implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) ) . ')';
}
$columns_sql = implode( ",\n ", $sql_columns ); $columns_sql = implode( ",\n ", $sql_columns );
$indexes_sql = implode( ",\n ", $sql_indexes ); $indexes_sql = implode( ",\n ", $sql_indexes );
@@ -157,8 +216,27 @@ final class TMDO_Schema_Manager {
// Bust the request-scoped table_exists cache so subsequent calls see the new table. // Bust the request-scoped table_exists cache so subsequent calls see the new table.
self::$table_exists_cache[ $table ] = true; self::$table_exists_cache[ $table ] = true;
// dbDelta reports no failure: record the definition only when every column is there.
$columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" );
$missing = array_diff( array_map( static fn( $field ) => self::sanitize_column_name( (string) $field['key'] ), $field_definitions ), $columns );
if ( $missing ) {
unset( self::$table_exists_cache[ $table ] );
TMDO_Logger::warning(
'schema.upgrade_failed',
array(
'table' => $table,
'missing' => array_values( $missing ),
'error' => $wpdb->last_error,
)
);
return false;
}
// 記錄 Schema 版本與定義 // 記錄 Schema 版本與定義
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions ); self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
if ( $existed ) {
self::end_deferral( $entity_type, $group_name );
}
/** /**
* Action: 表建立/升級完成 * Action: 表建立/升級完成
@@ -176,6 +254,12 @@ final class TMDO_Schema_Manager {
* applied definition is remembered per group (one autoloaded option), so * applied definition is remembered per group (one autoloaded option), so
* requests only run SHOW INDEX / ALTER when the declaration changes. * requests only run SHOW INDEX / ALTER when the declaration changes.
* *
* On a large table the ALTER takes seconds, so it runs only from WP-CLI or
* WP-Cron (ddl_allowed(); other requests schedule INDEXES_HOOK) and in one
* process at a time (MySQL GET_LOCK on index_lock_name()). Until every
* ALTER succeeds the group stays in pending_indexes() and the table works
* without the index.
*
* @param string $entity_type Entity type. * @param string $entity_type Entity type.
* @param string $group_name Group name. * @param string $group_name Group name.
* @return void * @return void
@@ -193,23 +277,249 @@ final class TMDO_Schema_Manager {
if ( ( $applied[ $id ] ?? '' ) === $signature ) { if ( ( $applied[ $id ] ?? '' ) === $signature ) {
return; return;
} }
if ( ! self::ddl_allowed() ) {
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
wp_schedule_single_event( time(), self::INDEXES_HOOK );
}
return;
}
$table = self::get_table_name( $entity_type, $group_name ); $table = self::get_table_name( $entity_type, $group_name );
if ( ! self::table_exists( $table ) ) { if ( ! self::table_exists( $table ) ) {
return; return;
} }
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) ); if ( ! self::lock() ) {
foreach ( $declared as $name => $keys ) { return; // Another process is adding indexes.
$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})" );
} }
try {
// SHOW INDEX (not the option, cached for this request) tells what another process already added.
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
$failed = false;
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 ) );
if ( false === $wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" ) ) {
$failed = true;
TMDO_Logger::warning(
'schema.index_failed',
array(
'table' => $table,
'index' => $index,
'error' => $wpdb->last_error,
)
);
}
}
$applied[ $id ] = $signature; if ( ! $failed ) {
update_option( self::INDEXES_OPTION, $applied, true ); $applied[ $id ] = $signature;
update_option( self::INDEXES_OPTION, $applied, true );
}
} finally {
self::unlock();
}
}
/**
* INDEXES_HOOK handler: the table upgrades and indexes a web request left.
* The cron request's init usually did them already; not with
* ALTERNATE_WP_CRON, where the event runs inside a visitor's request.
*
* @return void
*/
public static function ensure_pending_indexes(): void {
foreach ( array_keys( (array) get_option( self::DEFERRED_OPTION, array() ) ) as $id ) {
list( $entity_type, $group_name ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' );
$fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
if ( $fields ) {
self::create_or_upgrade_table( $entity_type, $group_name, $fields );
}
}
foreach ( self::pending_indexes() as $id ) {
list( $entity_type, $group_name ) = explode( '/', $id, 2 );
self::ensure_group_indexes( $entity_type, $group_name );
}
}
/**
* Whether the group's table upgrade is waiting for WP-CLI / WP-Cron; its
* keys then stay native (Hook Bus, query router).
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return bool
*/
public static function is_deferred( string $entity_type, string $group_name ): bool {
$deferred = get_option( self::DEFERRED_OPTION, array() );
return is_array( $deferred ) && isset( $deferred[ "{$entity_type}/{$group_name}" ] );
}
/**
* Whether an existing table's upgrade waits for WP-CLI / WP-Cron: altering
* a large table takes seconds to minutes, as for indexes. Until then the
* group reads and writes native meta (is_deferred()). Not in aeav_only,
* where the flat table is the only copy: that upgrade runs at once.
*
* @param string $entity_type Entity type.
* @param string $table Flat table name.
* @return bool
*/
public static function upgrade_waits( string $entity_type, string $table ): bool {
return self::table_exists( $table )
&& ! self::ddl_allowed()
&& TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type );
}
/**
* Record a deferred table upgrade and have WP-Cron run it.
*
* @param string $id "entity/group".
* @return void
*/
private static function defer_upgrade( string $id ): void {
self::record_deferred( $id );
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade.
}
}
/**
* Record a group as deferred (native until resynced()).
*
* @param string $id "entity/group".
* @return void
*/
private static function record_deferred( string $id ): void {
$deferred = get_option( self::DEFERRED_OPTION, array() );
$deferred = is_array( $deferred ) ? $deferred : array();
if ( ! isset( $deferred[ $id ] ) ) {
$deferred[ $id ] = time();
update_option( self::DEFERRED_OPTION, $deferred, true );
}
}
/**
* A deferred upgrade is done. Writes made meanwhile skipped the flat table,
* so the group stays native until a drift fix (tmdo_drift_resync, a minute
* later) has rebuilt it from native and calls resynced(). With no flat
* writes (disabled), nothing is behind.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return void
*/
private static function end_deferral( string $entity_type, string $group_name ): void {
if ( ! self::is_deferred( $entity_type, $group_name ) ) {
return;
}
if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) {
$deferred = (array) get_option( self::DEFERRED_OPTION, array() );
unset( $deferred[ "{$entity_type}/{$group_name}" ] );
update_option( self::DEFERRED_OPTION, $deferred, true );
return;
}
if ( ! wp_next_scheduled( TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ) ) {
wp_schedule_single_event( time() + MINUTE_IN_SECONDS, TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) );
}
}
/**
* A drift fix of the entity succeeded: its deferred groups whose table is
* upgraded read and write the flat table again.
*
* @param string $entity_type Entity type.
* @return void
*/
public static function resynced( string $entity_type ): void {
$deferred = get_option( self::DEFERRED_OPTION, array() );
if ( ! is_array( $deferred ) || ! $deferred ) {
return;
}
foreach ( array_keys( $deferred ) as $id ) {
list( $type, $group ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' );
if ( $type === $entity_type && self::get_stored_schema_hash( $type, $group ) === self::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) {
unset( $deferred[ $id ] );
}
}
update_option( self::DEFERRED_OPTION, $deferred, true );
}
/**
* Take the schema lock without waiting.
*
* add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two
* processes both "acquire" an option lock; GET_LOCK is atomic and is
* released by MySQL if this process dies. Re-entrant per connection.
*
* @return bool False when another process holds it.
*/
private static function lock(): bool {
global $wpdb;
if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) {
// HyperDB / LudicrousDB would send the SELECT GET_LOCK to a replica,
// where it locks nothing against the ALTER on the primary.
$wpdb->send_reads_to_masters();
}
return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', self::index_lock_name() ) );
}
/**
* Release the schema lock.
*
* @return void
*/
private static function unlock(): void {
global $wpdb;
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', self::index_lock_name() ) );
}
/**
* Key of the MySQL lock held while indexes are added. Lock names are
* server-wide, so SQL hashes it with DATABASE() to keep sites apart.
*
* @return string
*/
public static function index_lock_name(): string {
global $wpdb;
return $wpdb->prefix . self::INDEXES_OPTION;
}
/**
* Whether this request may alter flat tables: WP-CLI or WP-Cron only.
* Front-end, REST, admin-ajax, admin-post and even /wp-admin/ requests
* (init runs before auth_redirect()) can be a visitor's, who would wait
* for the ALTER. Filter: `tmdo_schema_ddl_allowed`.
*
* @return bool
*/
public static function ddl_allowed(): bool {
$allowed = ( defined( 'WP_CLI' ) && WP_CLI )
|| ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() );
return (bool) apply_filters( 'tmdo_schema_ddl_allowed', $allowed );
}
/**
* Registered groups whose declared indexes are not on their table yet.
*
* @return string[] "entity/group"
*/
public static function pending_indexes(): array {
$applied = get_option( self::INDEXES_OPTION, array() );
$applied = is_array( $applied ) ? $applied : array();
$pending = array();
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group_name ) {
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
if ( $declared && ( $applied[ "{$entity_type}/{$group_name}" ] ?? '' ) !== md5( (string) wp_json_encode( $declared ) )
&& self::table_exists( self::get_table_name( $entity_type, $group_name ) ) ) {
$pending[] = "{$entity_type}/{$group_name}";
}
}
}
return $pending;
} }
/** /**
@@ -31,6 +31,14 @@ final class TMDO_Shadow_Diff_Logger {
/** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */ /** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */
public const RATELIMIT_WINDOW = 300; public const RATELIMIT_WINDOW = 300;
/**
* Rows per entity type + key per KEY_WINDOW, across all objects: a key
* stale on every object (WooCommerce total_sales, written by direct SQL)
* must not log a row for each object read.
*/
public const KEY_LIMIT = 20;
public const KEY_WINDOW = 3600;
public static function table_name(): string { public static function table_name(): string {
global $wpdb; global $wpdb;
return $wpdb->prefix . 'wpdo_shadow_diffs'; return $wpdb->prefix . 'wpdo_shadow_diffs';
@@ -69,7 +77,7 @@ final class TMDO_Shadow_Diff_Logger {
} }
// 不一致 → 檢查 rate limit // 不一致 → 檢查 rate limit
if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) ) { if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) || self::key_over_limit( $entity_type, $meta_key ) ) {
return; return;
} }
@@ -183,6 +191,21 @@ final class TMDO_Shadow_Diff_Logger {
return false; return false;
} }
/**
* Count this diff against the key's KEY_LIMIT; true once the limit is
* reached within the current KEY_WINDOW (the window starts at its first row).
*/
private static function key_over_limit( string $entity_type, string $meta_key ): bool {
$key = 'wpdo_sd_kl_' . md5( "{$entity_type}:{$meta_key}" );
$state = get_transient( $key );
$state = is_array( $state ) ? $state : array( 0, time() );
if ( $state[0] >= self::KEY_LIMIT ) {
return true;
}
set_transient( $key, array( $state[0] + 1, $state[1] ), max( 1, $state[1] + self::KEY_WINDOW - time() ) );
return false;
}
/** /**
* 寫入 diff row * 寫入 diff row
* *
+35 -3
View File
@@ -70,10 +70,12 @@ final class TMDO_Type_Caster {
return $value; return $value;
case 'text': case 'text':
return (string) $value; return self::fit_text( is_scalar( $value ) ? (string) $value : maybe_serialize( $value ), $field_def );
case 'textarea': 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: default:
// 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為) // 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為)
@@ -81,6 +83,33 @@ final class TMDO_Type_Caster {
} }
} }
/** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */
public 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 轉換(讀取) * DB → PHP 轉換(讀取)
*/ */
@@ -111,9 +140,12 @@ final class TMDO_Type_Caster {
return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value; return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value;
case 'enum': case 'enum':
return (string) $value;
case 'text': case 'text':
case 'textarea': case 'textarea':
return (string) $value; // Arrays were stored serialized (to_db); plain strings pass through.
return TMDO_Safe_Unserialize::run( (string) $value );
default: default:
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1). // v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
@@ -2,19 +2,18 @@
/** /**
* Post entity field registration for WP Data Optimizer (v2.9.1). * Post entity field registration for WP Data Optimizer (v2.9.1).
* *
* Registers four post entity groups designed to absorb the bulk of * Registers three post entity groups designed to absorb the bulk of
* wp_postmeta rows that previously bypassed any flat-table strategy. * wp_postmeta rows that previously bypassed any flat-table strategy.
* All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV. * All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV.
* *
* Groups → flat tables (post_type targets): * Groups → flat tables (post_type targets):
* wp_core → wp_wpdo_post_wp_core (cross post_type) * wp_core → wp_wpdo_post_wp_core (cross post_type)
* attachment → wp_wpdo_post_attachment (attachment) * attachment → wp_wpdo_post_attachment (attachment)
* wc_product → wp_wpdo_post_wc_product (product)
* nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item) * nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item)
* *
* Pattern mirrors TMDO_Member_Fields::register(). Called from * Pattern mirrors TMDO_Member_Fields::register(). Called from
* TMDO_Core::run() before the wpdo_register_entity_fields action fires; * TMDO_Core::run() before the wpdo_register_entity_fields action fires;
* Schema_Manager materializes the four flat tables in init:1 * Schema_Manager materializes the three flat tables in init:1
* via process_pending_migrations() (idempotent via schema_hash compare). * via process_pending_migrations() (idempotent via schema_hash compare).
* *
* Keys not registered here (HivePress dynamic attrs, WPCS legacy keys, * Keys not registered here (HivePress dynamic attrs, WPCS legacy keys,
@@ -39,7 +38,10 @@ if ( ! defined( 'ABSPATH' ) ) {
} }
/** /**
* Registers four post entity groups. * Registers three post entity groups. WooCommerce product meta is not one of
* them (1.2.0): WooCommerce reads it directly and writes stock by SQL, so
* native meta stays its only copy. Existing wp_wpdo_post_wc_product tables
* are left alone (see `wp tmdo bridge-restore-native post --group=wc_product --retire`).
*/ */
final class TMDO_Post_Fields { final class TMDO_Post_Fields {
@@ -49,7 +51,6 @@ final class TMDO_Post_Fields {
*/ */
private const GROUP_POST_TYPES = array( private const GROUP_POST_TYPES = array(
'attachment' => 'attachment', 'attachment' => 'attachment',
'wc_product' => 'product',
'nav_menu_item' => 'nav_menu_item', 'nav_menu_item' => 'nav_menu_item',
); );
@@ -104,7 +105,6 @@ final class TMDO_Post_Fields {
self::register_wp_core_group(); self::register_wp_core_group();
self::register_attachment_group(); self::register_attachment_group();
self::register_wc_product_group();
self::register_nav_menu_item_group(); self::register_nav_menu_item_group();
} }
@@ -176,131 +176,6 @@ final class TMDO_Post_Fields {
); );
} }
/**
* WooCommerce product core meta keys (post_type=product).
* 19 keys — covers ~94% of wp_postmeta rows for products on dev10.
*
* @return void
*/
private static function register_wc_product_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'wc_product',
array(
array(
'key' => '_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Effective price',
),
array(
'key' => '_regular_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Regular price',
),
array(
'key' => '_sale_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Sale price',
),
array(
'key' => '_stock',
'type' => 'integer',
'searchable' => true,
'label' => 'Stock quantity',
),
array(
'key' => '_stock_status',
'type' => 'enum',
'searchable' => true,
'options' => array( 'instock', 'outofstock', 'onbackorder' ),
'label' => 'Stock status',
),
array(
'key' => '_sku',
'type' => 'text',
'searchable' => true,
'label' => 'Product SKU',
),
array(
'key' => '_manage_stock',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Manage stock?',
),
array(
'key' => '_backorders',
'type' => 'enum',
'options' => array( 'yes', 'no', 'notify' ),
'label' => 'Allow backorders?',
),
array(
'key' => '_sold_individually',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Sold individually?',
),
array(
'key' => '_virtual',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Virtual product?',
),
array(
'key' => '_downloadable',
'type' => 'enum',
'options' => array( 'yes', 'no' ),
'label' => 'Downloadable?',
),
array(
'key' => '_tax_class',
'type' => 'text',
'label' => 'Tax class slug',
),
array(
'key' => '_tax_status',
'type' => 'enum',
'options' => array( 'taxable', 'shipping', 'none' ),
'label' => 'Tax status',
),
array(
'key' => '_download_limit',
'type' => 'integer',
'label' => 'Download limit',
),
array(
'key' => '_download_expiry',
'type' => 'integer',
'label' => 'Download expiry days',
),
array(
'key' => '_product_version',
'type' => 'text',
'label' => 'WC version product was created on',
),
array(
'key' => '_wc_average_rating',
'type' => 'decimal',
'searchable' => true,
'label' => 'Average rating',
),
array(
'key' => '_wc_review_count',
'type' => 'integer',
'label' => 'Review count',
),
array(
'key' => 'total_sales',
'type' => 'integer',
'searchable' => true,
'label' => 'Total sales count',
),
)
);
}
/** /**
* Nav menu item meta keys (post_type=nav_menu_item). * Nav menu item meta keys (post_type=nav_menu_item).
* *
@@ -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;
}
}
+176 -24
View File
@@ -14,6 +14,16 @@
* the value Type_Caster returns); a NULL value deletes the native row. * 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: * - Entities with no row in the owning group's table are not touched:
* leftover native rows for them are outside the flat data. * 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 * 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 * final pass re-copies rows updated since the first run started. With
@@ -52,14 +62,19 @@ final class TMDO_Native_Restorer {
* @param int $batch Rows per batch. * @param int $batch Rows per batch.
* @param bool $demote After a complete run, switch the entity to dual_write. * @param bool $demote After a complete run, switch the entity to dual_write.
* @param string $group Only this group ('' = every group). * @param string $group Only this group ('' = every group).
* @return array{groups: array<string, array{rows:int, written:int}>, demoted: bool} * @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, string $group = '' ): array { public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '', string $policy = '' ): array {
global $wpdb; 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( $report = array(
'groups' => array(), 'groups' => array(),
'demoted' => false, 'demoted' => false,
'policy' => $policy,
); );
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); $adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) { if ( ! $adapter ) {
@@ -83,8 +98,12 @@ final class TMDO_Native_Restorer {
} }
foreach ( $tables as $group => $table ) { foreach ( $tables as $group => $table ) {
$entry = array( $entry = array(
'rows' => 0, 'rows' => 0,
'written' => 0, 'written' => 0,
'missing' => 0,
'differs' => 0,
'same' => 0,
'default_skipped' => 0,
); );
$key = "{$entity_type}/{$group}"; $key = "{$entity_type}/{$group}";
$last = $dry_run ? 0 : (int) ( $cursor[ $key ] ?? 0 ); $last = $dry_run ? 0 : (int) ( $cursor[ $key ] ?? 0 );
@@ -94,9 +113,11 @@ final class TMDO_Native_Restorer {
$entry['rows'] += $fetched; $entry['rows'] += $fetched;
if ( $fetched ) { if ( $fetched ) {
$last = (int) end( $rows )[ $id_col ]; $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 ) { if ( ! $dry_run ) {
$entry['written'] += self::write_rows( $entity_type, $group, $adapter, $rows ); $cursor[ $key ] = $last;
$cursor[ $key ] = $last;
update_option( self::CURSOR_OPTION, $cursor, false ); update_option( self::CURSOR_OPTION, $cursor, false );
} }
} }
@@ -112,7 +133,7 @@ final class TMDO_Native_Restorer {
// Catch-up: writes that landed in the flat tables after a batch passed their row. // Catch-up: writes that landed in the flat tables after a batch passed their row.
foreach ( $tables as $group => $table ) { foreach ( $tables as $group => $table ) {
$rows = self::fetch( $entity_type, $table, $id_col, 't.updated_at >= %s', array( $started[ $entity_type ] ) ); $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 ) { foreach ( array_keys( $tables ) as $group ) {
@@ -235,24 +256,87 @@ final class TMDO_Native_Restorer {
} }
/** /**
* Replace the native meta of the keys this group owns for each row. * Compare the native meta of the keys this group owns with the flat rows,
* and (when $write) bring native in line according to $policy.
* *
* @param string $entity_type Entity type. * @param string $entity_type Entity type.
* @param string $group Group the rows come from. * @param string $group Group the rows come from.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param array<int, array<string, mixed>> $rows Flat rows. * @param array<int, array<string, mixed>> $rows Flat rows.
* @return int Native values written. * @param string $policy 'overwrite' | 'only_missing'.
* @param bool $write False = classify only (dry run).
* @return array{written:int, missing:int, differs:int, same:int, default_skipped:int}
*/ */
private static function write_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): int { private static function apply_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows, string $policy, bool $write ): array {
global $wpdb; $counts = array(
if ( empty( $rows ) ) { 'written' => 0,
return 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(); $native = self::native_values( $entity_type, $adapter, array_keys( $wanted ), array_keys( $fields ) );
$id_col = $adapter->get_entity_id_column();
$deletes = array(); $deletes = array();
$inserts = 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 ) { foreach ( $rows as $row ) {
$id = (int) $row[ $id_col ]; $id = (int) $row[ $id_col ];
$post_type = 'post' === $entity_type ? (string) ( $row['__post_type'] ?? '' ) : ''; $post_type = 'post' === $entity_type ? (string) ( $row['__post_type'] ?? '' ) : '';
@@ -262,25 +346,93 @@ final class TMDO_Native_Restorer {
if ( null === $owner || $owner['group'] !== $group || ! array_key_exists( $col, $row ) ) { if ( null === $owner || $owner['group'] !== $group || ! array_key_exists( $col, $row ) ) {
continue; continue;
} }
$deletes[ $field['key'] ][] = $id; $fields[ $field['key'] ] = $field;
if ( null !== $row[ $col ] ) { $wanted[ $id ][ $field['key'] ] = null === $row[ $col ]
$inserts[] = array( $id, $field['key'], (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) ) ); ? 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 ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM `{$meta_table}` WHERE meta_key = %s AND `{$id_col}` IN ({$in})", $meta_key ) );
} }
if ( $inserts ) { if ( $inserts ) {
$values = implode( ', ', array_fill( 0, count( $inserts ), '(%d, %s, %s)' ) ); $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 ) ) ); $wpdb->query( $wpdb->prepare( "INSERT INTO `{$meta_table}` (`{$id_col}`, meta_key, meta_value) VALUES {$values}", ...array_merge( ...$inserts ) ) );
} }
foreach ( $ids as $id ) {
foreach ( array_unique( array_map( static fn( $row ) => (int) $row[ $id_col ], $rows ) ) as $id ) {
wp_cache_delete( $id, "{$entity_type}_meta" ); 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 );
} }
} }
@@ -99,11 +99,12 @@ final class TMDO_Post_Migration {
/** /**
* Bulk SQL pivot for one group: select managed keys from wp_postmeta, * Bulk SQL pivot for one group: select managed keys from wp_postmeta,
* filter by post_type, pivot via MAX(CASE) GROUP BY post_id, UPSERT * filter by post_type, pivot via MAX(CASE) GROUP BY post_id, UPSERT
* into the group's flat table. * into the group's flat table. Groups with json fields go row by row
* through backfill_group_json() instead.
* *
* @param string $group_name Entity group name (e.g. 'wc_product'). * @param string $group_name Entity group name (e.g. 'hp_listing_core').
* @return array{migrated:int,group:string,post_type:string|null} * @return array{migrated:int,group:string,post_type:string|null}
* @throws InvalidArgumentException When group is not registered. * @throws InvalidArgumentException When group is not registered, or is a restored orphan group.
* @throws RuntimeException When the pivot SQL fails. * @throws RuntimeException When the pivot SQL fails.
*/ */
public static function backfill_group( string $group_name ): array { public static function backfill_group( string $group_name ): array {
@@ -115,8 +116,30 @@ final class TMDO_Post_Migration {
'Unknown post entity group: ' . esc_html( $group_name ) 'Unknown post entity group: ' . esc_html( $group_name )
); );
} }
if ( ! in_array( $group_name, self::backfillable_groups(), true ) ) {
throw new InvalidArgumentException(
'Orphan post group, its values live only in the flat table: ' . esc_html( $group_name )
);
}
$post_type = self::group_post_type( $group_name );
// json values need PHP-level unserialize → json encode, which the bulk
// pivot below cannot do: backfill such groups row by row instead.
if ( in_array( 'json', array_column( $fields, 'type' ), true ) ) {
$result = self::backfill_group_json( $group_name, array( 'resume' => false ) );
if ( ! empty( $result['error'] ) || ! empty( $result['errors'] ) ) {
throw new RuntimeException(
'backfill_group failed for ' . esc_html( $group_name ) . ': ' . esc_html( (string) ( $result['error'] ?? $result['errors'] . ' row error(s)' ) )
);
}
return array(
'migrated' => (int) $result['migrated'],
'group' => $group_name,
'post_type' => $post_type,
);
}
$post_type = self::group_post_type( $group_name );
$flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name ); $flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name );
// Introspect target table columns so we only pivot fields that actually // Introspect target table columns so we only pivot fields that actually
@@ -131,16 +154,10 @@ final class TMDO_Post_Migration {
} }
// Build column list and CASE expressions. // Build column list and CASE expressions.
// Skip json/textarea types from the bulk pivot — those need row-by-row
// unserialize handling (deferred to v2.9.4 backfill_unserialize phase).
$columns = array(); $columns = array();
$cases = array(); $cases = array();
$update_parts = array(); $update_parts = array();
foreach ( $fields as $field ) { foreach ( $fields as $field ) {
$type = $field['type'] ?? 'text';
if ( 'json' === $type ) {
continue;
}
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] ); $col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
if ( ! isset( $existing_cols[ $col ] ) ) { if ( ! isset( $existing_cols[ $col ] ) ) {
continue; // Column not present in this table — skip silently. continue; // Column not present in this table — skip silently.
@@ -228,6 +245,10 @@ final class TMDO_Post_Migration {
'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only' '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; global $wpdb;
$keys = self::get_managed_keys(); $keys = self::get_managed_keys();
@@ -532,6 +553,17 @@ final class TMDO_Post_Migration {
// Helpers // Helpers
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
/**
* Registered post groups that can be backfilled from wp_postmeta. Orphan
* groups restored from wpdo_registry_meta are left out: their values live
* only in the flat table.
*
* @return string[]
*/
public static function backfillable_groups(): array {
return TMDO_Entity_Migration_Engine::backfillable_groups( self::ENTITY_TYPE );
}
/** /**
* All meta_keys registered for any post entity group. * All meta_keys registered for any post entity group.
* *
@@ -46,6 +46,10 @@ class TMDO_Phase_Cleanup extends TMDO_Migration_Phase_Base {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) { if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" ); 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'] ) ) { if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) {
throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' ); throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' );
} }
+699 -87
View File
@@ -16,21 +16,27 @@
* Hook order: * Hook order:
* 1. pre_get_posts — strip registered keys from meta_query, store in * 1. pre_get_posts — strip registered keys from meta_query, store in
* $query->set( 'wpdo_post_clauses', $routed ) * $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 * 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 / * 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, * post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
* a single-post-type search with explicit statuses and date ordering * a single-post-type search with explicit statuses and date ordering
* INNER JOINs the table, filters the copies, orders by the copied * INNER JOINs the table, filters the copies and orders by the copied
* post_date and skips GROUP BY (one row per post), so MySQL can walk * post_date (ID tie-breaks read as the flat post_id), so MySQL can walk
* `idx_post_status_date` and stop at the LIMIT. * `idx_post_status_date_id` 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 * 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 * 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 * same query, kept in the object cache until a post is saved or deleted, a
* post saves, and flat writes via TMDO_Hook_Bus). * flat write goes through TMDO_Hook_Bus (bump_count_version()) or terms'
* last_changed moves. Not posts' last_changed: WordPress moves it on any post
* meta write, which themes do on page views (Blocksy's dynamic CSS), and a
* routed search reads no unmanaged meta.
* *
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes * 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
* only entity_type='post'. * only entity_type='post'.
@@ -58,12 +64,24 @@ class TMDO_Post_Query_Router {
/** Query var holding the status / date copy route, when one applies. */ /** Query var holding the status / date copy route, when one applies. */
private const COPY_VAR = 'wpdo_post_copy_route'; 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. */ /** Query var holding the COUNT for found_posts_query. */
private const COUNT_VAR = 'wpdo_post_count_sql'; private const COUNT_VAR = 'wpdo_post_count_sql';
/** Object cache group for found-rows counts. */ /** Object cache group for found-rows counts. */
private const COUNT_CACHE_GROUP = 'tmdo_found_rows'; private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
/** The last_changed group the cached counts follow (bump_count_version()). */
public const COUNT_VERSION_GROUP = 'tmdo_posts';
/** Seconds a "every post of the type has a flat row" answer is reused. */
private const COVERAGE_TTL = 900;
/** Comparison operators the router can express on a flat column. */
private const COMPARES = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' );
/** /**
* Per-request answer to "are this group's copies usable?". * Per-request answer to "are this group's copies usable?".
* *
@@ -77,13 +95,48 @@ class TMDO_Post_Query_Router {
* @return void * @return void
*/ */
public function register_hooks(): 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_join', array( $this, 'posts_join' ), 10, 2 );
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 ); add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 ); add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
add_filter( 'posts_request', array( $this, 'posts_request' ), 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 ); add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
add_action( 'wp_insert_post', array( $this, 'add_flat_rows' ), 10, 3 );
}
/**
* A new post of a type with its own groups gets an empty row in each, so
* that "every post of the type has a flat row" keeps holding and sort-only
* searches can drive from the flat table (see flat_first()).
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post.
* @param bool $update Whether an existing post was updated.
* @return void
*/
public function add_flat_rows( $post_id, $post, $update ): void {
if ( $update || ! $post instanceof \WP_Post ) {
return;
}
global $wpdb;
foreach ( TMDO_Post_Fields::groups_for_post_type( $post->post_type ) as $group ) {
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
// With the status / date copies: this runs after transition_post_status,
// and one NULL copy turns the copy route off for the whole group.
$copies = class_exists( 'TMDO_Post_Status_Sync' ) ? TMDO_Post_Status_Sync::insert_columns( (int) $post_id, $group ) : array();
if ( $copies ) {
$wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`, `post_status`, `post_date`) VALUES (%d, %s, %s)", (int) $post_id, $copies['post_status'], $copies['post_date'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
continue;
}
$wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`) VALUES (%d)", (int) $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
} }
/** /**
@@ -93,6 +146,11 @@ class TMDO_Post_Query_Router {
* @return void * @return void
*/ */
public function pre_get_posts( \WP_Query $query ): void { public function pre_get_posts( \WP_Query $query ): void {
// The router's query vars are its own: drop any a caller passed in.
foreach ( array( self::QUERY_VAR, self::SORT_VAR, self::COPY_VAR, self::COUNT_VAR ) as $var ) {
$query->set( $var, '' );
}
// Mode gate: only run when post mode is reads_from_flat. // Mode gate: only run when post mode is reads_from_flat.
if ( ! self::is_router_active() ) { if ( ! self::is_router_active() ) {
return; return;
@@ -103,60 +161,135 @@ class TMDO_Post_Query_Router {
return; 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' ); $post_types = (array) $query->get( 'post_type' );
if ( empty( $post_types ) ) { if ( 1 !== count( $post_types ) ) {
return; return;
} }
$pt = (string) reset( $post_types );
$raw_meta_query = (array) $query->get( 'meta_query' ); $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. $routed = array(); // Keyed by post_type.
$remaining = array(); $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.
// A key comparison other than equality (meta_compare_key LIKE …) matches
// other keys too: not a test on one flat column.
$meta_key = $query->get( 'meta_key' );
$key_exact = in_array( (string) $query->get( 'meta_compare_key' ), array( '', '=' ), true );
$take_key = $key_exact && 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 );
}
// meta_key alone (no meta_value, not ordered by): WP_Query adds a clause
// that tests the key exists — HivePress's featured-listing lookup. Any
// orderby naming meta_value, meta_value_num or the key sorts by that
// clause, which clearing meta_key would drop.
$exists_key = ! $take_key && $key_exact && is_string( $meta_key ) && '' !== $meta_key
&& '' === (string) $query->get( 'meta_value' ) && '' === (string) $query->get( 'meta_compare' )
&& ! array_intersect( $named, array( 'meta_value', 'meta_value_num', $meta_key ) )
&& null !== self::field_for( array( 'key' => $meta_key ), $pt );
if ( $exists_key ) {
$route = self::route_clause(
array(
'key' => $meta_key,
'compare' => 'EXISTS',
),
self::field_for( array( 'key' => $meta_key ), $pt )
);
if ( ! $route ) {
return;
}
$routed[ $pt ][] = $route;
}
foreach ( $raw_meta_query as $k => $clause ) { foreach ( $raw_meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { if ( 'relation' === $k || ! is_array( $clause ) ) {
$remaining[ $k ] = $clause;
continue;
}
if ( ! isset( $clause['key'] ) ) {
// Sort shape: matches every post, only orders by the key.
$name = 'AND' === $relation ? self::sort_shape( $clause ) : '';
// Matches every post and orders nothing here (a copy of a search's meta
// query) — unless orderby names it, or meta_value(_num) / its key, which
// sort by it when it is WordPress's primary (first) clause.
if ( '' !== $name && ! array_intersect( $named, array( $name, 'meta_value', 'meta_value_num', (string) ( $clause[ $name ]['key'] ?? '' ) ) )
&& self::field_for( $clause[ $name ], $pt ) ) {
continue;
}
$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; $remaining[ $k ] = $clause;
continue; continue;
} }
$matched = false; $field = self::field_for( $clause, $pt );
foreach ( $post_types as $pt ) { if ( ! $field ) {
$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; $remaining[ $k ] = $clause;
continue;
} }
$route = self::route_clause( $clause, $field );
if ( ! $route || 'AND' !== $relation ) {
return;
}
// Named by orderby: only as the sole ordering, on a column that
// orders like native (otherwise the name would be lost).
if ( is_string( $k ) && in_array( $k, $named, true ) ) {
if ( $k !== $sort_name || ! self::sortable( $route, $field ) ) {
return;
}
$sort = self::sort_for( $pt, $route, $direction );
}
$routed[ $pt ][] = $route;
} }
if ( empty( $routed ) ) { if ( empty( $routed ) && empty( $sort ) ) {
return; return;
} }
if ( $take_key || $exists_key ) {
$query->set( 'meta_key', '' );
}
if ( $sort ) {
$query->set( self::SORT_VAR, $sort );
}
// Preserve relation if surviving clauses still have it. // Preserve relation if surviving clauses still have it.
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) { if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
@@ -173,7 +306,7 @@ class TMDO_Post_Query_Router {
} }
/** /**
* LEFT JOIN flat tables for each routed post_type. * LEFT JOIN the flat table of each routed group.
* *
* @param string|null $join Current JOIN SQL. * @param string|null $join Current JOIN SQL.
* @param \WP_Query $query Current WP_Query. * @param \WP_Query $query Current WP_Query.
@@ -182,19 +315,20 @@ class TMDO_Post_Query_Router {
public function posts_join( ?string $join, \WP_Query $query ): string { public function posts_join( ?string $join, \WP_Query $query ): string {
$join = (string) ( $join ?? '' ); $join = (string) ( $join ?? '' );
$routed = $query->get( self::QUERY_VAR ); $routed = $query->get( self::QUERY_VAR );
if ( empty( $routed ) || ! is_array( $routed ) ) { $sort = $query->get( self::SORT_VAR );
return $join; $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; global $wpdb;
foreach ( $routed as $post_type => $clauses ) { foreach ( $pairs as list( $post_type, $group ) ) {
// 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;
}
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group ); $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
$alias = self::table_alias( $post_type, $group ); $alias = self::table_alias( $post_type, $group );
@@ -203,6 +337,7 @@ class TMDO_Post_Query_Router {
continue; continue;
} }
// The copy route only applies to single-group searches.
$type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT'; $type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT';
$join .= " {$type} JOIN `{$table}` AS `{$alias}` " $join .= " {$type} JOIN `{$table}` AS `{$alias}` "
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)"; . "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
@@ -248,7 +383,8 @@ class TMDO_Post_Query_Router {
} }
/** /**
* Order by the flat table's post_date copy when the copy route applies. * Order by the routed meta orderby column (ties by ID), or by the flat
* table's post_date copy when the copy route applies.
* *
* @param string|null $orderby Current ORDER BY SQL. * @param string|null $orderby Current ORDER BY SQL.
* @param \WP_Query $query Current WP_Query. * @param \WP_Query $query Current WP_Query.
@@ -256,33 +392,17 @@ class TMDO_Post_Query_Router {
*/ */
public function posts_orderby( ?string $orderby, \WP_Query $query ): string { public function posts_orderby( ?string $orderby, \WP_Query $query ): string {
$orderby = (string) ( $orderby ?? '' ); $orderby = (string) ( $orderby ?? '' );
$copy = $query->get( self::COPY_VAR ); $sort = $query->get( self::SORT_VAR );
if ( ! empty( $sort ) ) {
global $wpdb;
return '`' . self::table_alias( $sort['post_type'], $sort['group'] ) . '`.`' . esc_sql( $sort['column'] ) . '` '
. $sort['order'] . ", `{$wpdb->posts}`.`ID` DESC";
}
$copy = $query->get( self::COPY_VAR );
if ( empty( $copy ) ) { if ( empty( $copy ) ) {
return $orderby; return $orderby;
} }
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order']; return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . ( 'ASC' === $copy['order'] ? 'ASC' : 'DESC' );
}
/**
* Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
*
* @param string|null $groupby Current GROUP BY SQL.
* @param \WP_Query $query Current WP_Query.
* @return string Modified GROUP BY SQL.
*/
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
$groupby = (string) ( $groupby ?? '' );
$routed = $query->get( self::QUERY_VAR );
if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
return $groupby;
}
global $wpdb;
if ( '' === trim( $groupby ) ) {
$groupby = "`{$wpdb->posts}`.`ID`";
}
return $groupby;
} }
/** /**
@@ -294,15 +414,61 @@ class TMDO_Post_Query_Router {
*/ */
public function posts_request( ?string $request, \WP_Query $query ): string { public function posts_request( ?string $request, \WP_Query $query ): string {
$request = (string) ( $request ?? '' ); $request = (string) ( $request ?? '' );
if ( empty( $query->get( self::QUERY_VAR ) ) || false === strpos( $request, 'SQL_CALC_FOUND_ROWS' ) ) { $routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) );
if ( ! $routed ) {
return $request;
}
$counted = false !== strpos( $request, 'SQL_CALC_FOUND_ROWS' );
$request = self::routed_request( $request, $query );
if ( ! $counted ) {
return $request;
}
global $wpdb;
$matches = (string) preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
$not_in = " AND {$wpdb->posts}.ID NOT IN (";
// post__not_in (HivePress sends a random set per page) would give every
// search its own cache key: count without it (cached) minus the excluded
// posts that match (primary-key lookups, so no forced flat-first order).
if ( 1 === substr_count( $matches, $not_in ) && preg_match( '/' . preg_quote( $not_in, '/' ) . '([\d,]+)\)/', $matches, $m ) ) {
$excluded = str_replace( $m[0], " AND {$wpdb->posts}.ID IN ({$m[1]})", (string) preg_replace( '/^SELECT STRAIGHT_JOIN /', 'SELECT ', $matches ) );
$query->set(
self::COUNT_VAR,
array(
'SELECT COUNT(*) FROM ( ' . str_replace( $m[0], '', $matches ) . ' ) AS tmdo_found',
"SELECT COUNT(*) FROM ( {$excluded} ) AS tmdo_found",
)
);
return $request; return $request;
} }
$request = preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
$matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" ); $query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
return $request; 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. * Answer found rows from the cached COUNT of a routed search.
* *
@@ -315,13 +481,21 @@ class TMDO_Post_Query_Router {
if ( empty( $count_sql ) ) { if ( empty( $count_sql ) ) {
return (string) ( $sql ?? '' ); return (string) ( $sql ?? '' );
} }
$key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' ); global $wpdb;
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP ); list( $count_sql, $excluded_sql ) = array_pad( (array) $count_sql, 2, '' );
// Keyword searches carry wpdb's placeholder escape for "%", which is new
// in every request: without it the key would never match again.
$stable = method_exists( $wpdb, 'remove_placeholder_escape' ) ? $wpdb->remove_placeholder_escape( $count_sql ) : $count_sql;
$key = md5( $stable ) . ':' . wp_cache_get_last_changed( self::COUNT_VERSION_GROUP ) . ':' . wp_cache_get_last_changed( 'terms' );
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
if ( false === $total ) { if ( false === $total ) {
global $wpdb;
$total = (int) $wpdb->get_var( $count_sql ); $total = (int) $wpdb->get_var( $count_sql );
wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS ); wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS );
} }
if ( '' !== $excluded_sql ) {
$total = max( 0, (int) $total - (int) $wpdb->get_var( $excluded_sql ) );
}
return 'SELECT ' . (int) $total; return 'SELECT ' . (int) $total;
} }
@@ -329,6 +503,17 @@ class TMDO_Post_Query_Router {
// Helpers // Helpers
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
/**
* A post was saved or deleted, or a managed key written: cached routed
* counts are stale. Hooked on clean_post_cache whatever the mode, so a
* count cached before the router was last active is not reused.
*
* @return void
*/
public static function bump_count_version(): void {
wp_cache_set_last_changed( self::COUNT_VERSION_GROUP );
}
/** /**
* Whether the router is mode-active (post mode is shadow_read or aeav_only). * Whether the router is mode-active (post mode is shadow_read or aeav_only).
* *
@@ -350,6 +535,57 @@ class TMDO_Post_Query_Router {
self::$copies_usable = array(); self::$copies_usable = array();
} }
/**
* Every post of the type has a row in the group's flat table. One
* anti-join per group, reused for COVERAGE_TTL and per post-mode change.
* A post inserted by direct SQL (no wp_insert_post) is seen at the next
* check at the latest; the `tmdo_router_flat_first` filter turns the
* flat-first route off.
*
* @param string $post_type Post type.
* @param string $group Entity group name.
* @return bool
*/
private static function covers_all( string $post_type, string $group ): bool {
$key = self::coverage_key( $post_type, $group );
$hit = get_transient( $key );
if ( false === $hit ) {
global $wpdb;
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
$missing = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} p LEFT JOIN `{$table}` f ON f.post_id = p.ID WHERE p.post_type = %s AND f.post_id IS NULL LIMIT 1", $post_type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$hit = null === $missing ? 'yes' : 'no';
set_transient( $key, $hit, self::COVERAGE_TTL );
}
return 'yes' === $hit;
}
/**
* Forget every cached coverage answer (tests, or after bulk writes that
* bypass wp_insert_post).
*
* @return void
*/
public static function forget_coverage(): void {
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
foreach ( TMDO_Entity_Registry::get_group_post_types( 'post', $group ) as $post_type ) {
delete_transient( self::coverage_key( $post_type, $group ) );
}
}
}
/**
* Transient name of a coverage answer; a post-mode change starts a new one.
*
* @param string $post_type Post type.
* @param string $group Entity group name.
* @return string
*/
private static function coverage_key( string $post_type, string $group ): string {
$entered = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
$since = is_array( $entered ) ? (int) ( $entered['post'] ?? 0 ) : 0;
return 'tmdo_cov_' . md5( "{$post_type}:{$group}:{$since}" );
}
/** /**
* The status / date copy route for a routed query, or [] when it does not apply. * The status / date copy route for a routed query, or [] when it does not apply.
* *
@@ -363,7 +599,8 @@ class TMDO_Post_Query_Router {
} }
$post_type = (string) array_key_first( $routed ); $post_type = (string) array_key_first( $routed );
$groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) ); $groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) );
if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true ) ) { if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true )
|| in_array( 'NOT EXISTS', array_column( $routed[ $post_type ], 'compare' ), true ) ) {
return array(); return array();
} }
$statuses = $query->get( 'post_status' ); $statuses = $query->get( 'post_status' );
@@ -411,6 +648,378 @@ class TMDO_Post_Query_Router {
return self::$copies_usable[ $group ]; 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 last `wp tmdo drift-check` found the group out of step with native
* meta. A group never checked keeps routing, and so does every group in
* aeav_only, where native is stale by design and no fallback.
*
* @param string $group Entity group name.
* @return bool
*/
private static function drifting( string $group ): bool {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) {
return false;
}
if ( TMDO_Schema_Manager::is_deferred( 'post', $group ) ) {
return true; // Table upgrade waiting for WP-CLI / WP-Cron.
}
$status = get_option( TMDO_Drift_Check::OPTION, array() );
$last = is_array( $status ) ? ( $status['post'][ $group ] ?? null ) : null;
return is_array( $last ) && (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0;
}
/**
* The routed form of a clause, or null when the flat column would not
* compare the way WordPress compares the meta_value string, or when the
* group is known to drift from native meta.
*
* Native casts meta_value by the clause type (CHAR when absent); flat
* columns are typed. Routed: EXISTS / NOT EXISTS; string columns with
* 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'] ?? '=' ) ) );
// Without 'value', WP_Meta_Query checks only that the key exists (it adds a
// value condition only when one is set): HivePress's featured-listing
// lookup, run on every search page, is array( 'key' => 'hp_featured' ).
if ( ! array_key_exists( 'value', $clause ) && ! in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
$compare = 'EXISTS';
}
$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 || self::drifting( (string) $field['group'] ) ) {
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::copy_ordered( self::flat_first( self::without_found_rows( $request ), $query ), $query );
}
/**
* The copy route's request without WordPress's GROUP BY (when the flat row
* is the only join) and with posts.ID tie-breaks read as the flat post_id,
* each in its own direction, once. MariaDB then walks the
* (post_status, post_date, post_id) index and stops at the LIMIT instead
* of sorting every match (dev36, 235k of 1.3M listings: 1.7 s → 3 ms).
*
* @param string $request SELECT statement.
* @param \WP_Query $query Current WP_Query.
* @return string
*/
private static function copy_ordered( string $request, \WP_Query $query ): string {
$copy = $query->get( self::COPY_VAR );
if ( empty( $copy ) ) {
return $request;
}
return self::ties_on_post_id( self::without_group_by( $request ), self::table_alias( $copy['post_type'], $copy['group'] ), '' );
}
/**
* ORDER BY terms that are posts.ID alone (with or without a direction)
* read as the equal flat post_id — in $direction, or each in its own when
* $direction is '' — and only once. posts.ID inside an expression a plugin
* added (FIELD(), IN …) is left alone: a direction inserted there breaks
* the SQL.
*
* @param string $request SELECT statement.
* @param string $alias Flat table alias.
* @param string $direction 'ASC', 'DESC' or '' (keep each term's own).
* @return string
*/
private static function ties_on_post_id( string $request, string $alias, string $direction ): string {
global $wpdb;
$tie = "`{$alias}`.`post_id` ";
$id = '`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?';
return self::replaced(
preg_replace_callback(
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
static function ( $m ) use ( $id, $tie, $direction ) {
$order = self::replaced(
preg_replace_callback(
'/(?<=ORDER BY |,)(\s*)' . $id . '(?:\s+(ASC|DESC)\b)?(?=\s*(?:,|\bLIMIT\b|$))/i',
static fn( $t ) => $t[1] . $tie . ( '' !== $direction ? $direction : ( 'DESC' === strtoupper( $t[2] ?? '' ) ? 'DESC' : 'ASC' ) ),
$m[0]
),
$m[0]
);
return self::replaced( preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . '(ASC|DESC))(\s*,\s*' . preg_quote( $tie, '/' ) . '\2)+/', '$1', $order ), $order );
},
$request
),
$request
);
}
/**
* A preg_* result, or the text it was given when PCRE failed (backtrack
* or JIT stack limit on a very long request): the query stays unrouted
* in shape rather than empty.
*
* @param string|null $result preg_* return value.
* @param string $fallback Input text.
* @return string
*/
private static function replaced( ?string $result, string $fallback ): string {
return $result ?? $fallback;
}
/**
* WordPress groups by posts.ID for any tax or meta query. With a flat row
* (one per post) as the only join there is nothing to collapse, and the
* GROUP BY makes MySQL sort the whole match in a temporary table
* (WooCommerce's product_visibility NOT IN on searches is a subquery).
*
* @param string $request SELECT statement.
* @return string
*/
private static function without_group_by( string $request ): string {
global $wpdb;
if ( 1 !== preg_match_all( '/\bJOIN\b/i', $request ) ) {
return $request;
}
return self::replaced( preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', $request, 1 ), $request );
}
/**
* 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( $sort ) || ! is_array( $sort ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
return $request;
}
if ( empty( $routed ) || ! is_array( $routed ) ) {
// Sort only: the LEFT JOIN keeps posts without a flat row, so the
// flat table may drive only when every post of the type has one.
$post_type = (string) $sort['post_type'];
$group = (string) $sort['group'];
if ( ! self::covers_all( $post_type, $group ) ) {
return $request;
}
} else {
$post_type = (string) array_key_first( $routed );
$groups = array_unique( array_column( $routed[ $post_type ], 'group' ) );
$compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) );
if ( 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) {
return $request;
}
$group = (string) $groups[0];
}
foreach ( array( 'p', 'page_id', 'name', 'pagename', 'attachment_id', 'post__in', 'post_parent', 'post_parent__in', 'author', 'author__in', 'author_name' ) as $var ) {
if ( ! empty( $query->get( $var ) ) ) {
return $request;
}
}
if ( ! apply_filters( 'tmdo_router_flat_first', true, $query ) ) {
return $request;
}
global $wpdb;
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
$alias = self::table_alias( $post_type, $group );
$on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
$join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}";
if ( 1 !== substr_count( $request, $join ) ) {
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;
}
$flat_first = self::without_group_by( (string) $flat_first );
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat
// post_id, once, in the sort's direction: a (column, post_id) index then
// returns rows in order with no filesort. Ties are ordered by ID the same
// way as the column (natively ID DESC); pagination stays stable.
$direction = 'ASC' === strtoupper( (string) $sort['order'] ) ? 'ASC' : 'DESC';
$flat_first = self::ties_on_post_id( $flat_first, $alias, $direction );
return self::replaced( preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', $flat_first, 1 ), $request );
}
/**
* 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 self::replaced( preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 ), $request );
}
/**
* 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. * Generate a deterministic JOIN alias for a (post_type, group) pair.
* *
@@ -429,9 +1038,8 @@ class TMDO_Post_Query_Router {
* @return string Sanitized operator (defaults to '='). * @return string Sanitized operator (defaults to '=').
*/ */
private static function sanitize_compare( string $compare ): string { 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 ) ); $compare = strtoupper( trim( $compare ) );
return in_array( $compare, $allowed, true ) ? $compare : '='; return in_array( $compare, self::COMPARES, true ) ? $compare : '=';
} }
/** /**
@@ -468,6 +1076,10 @@ class TMDO_Post_Query_Router {
return ' AND ' . $col_sql . ' ' . $op; 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 ); return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value );
} }
} }
@@ -310,7 +310,7 @@ class TMDO_Snapshot_Manager {
} }
$table = $wpdb->prefix . self::TABLE_SLUG; $table = $wpdb->prefix . self::TABLE_SLUG;
$deleted = $wpdb->delete( $table, array( 'snapshot_id' => $snapshot_id ), array( '%s' ) ); // phpcs:ignore WordPress.DB $deleted = $wpdb->delete( $table, array( 'snapshot_id' => $snapshot_id ), array( '%s' ) ); // phpcs:ignore WordPress.DB
TMDO_Logger::info( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) ); TMDO_Logger::notice( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) );
return false !== $deleted; return false !== $deleted;
} }
@@ -210,7 +210,15 @@ class TMDO_Zone_Archive {
global $wpdb; global $wpdb;
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
// Rows already archived are skipped: the sweep used to copy the same
// trashed meta again every day (2.4.2 PR-136).
$table = self::table();
// Without the index each candidate scans the archive: wait for `wp tmdo install`.
if ( ! TMDO_IS_SQLITE && ! $wpdb->get_var( "SHOW INDEX FROM `{$table}` WHERE Key_name = 'idx_original_meta'" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- internal table name
return 0;
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
$rows = $wpdb->get_results( $rows = $wpdb->get_results(
$wpdb->prepare( $wpdb->prepare(
"SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type "SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
@@ -218,12 +226,14 @@ class TMDO_Zone_Archive {
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_status = 'trash' WHERE p.post_status = 'trash'
AND p.post_modified_gmt < %s AND p.post_modified_gmt < %s
AND NOT EXISTS ( SELECT 1 FROM `{$table}` a WHERE a.original_meta_id = pm.meta_id )
LIMIT %d", LIMIT %d",
$cutoff, $cutoff,
$limit $limit
), ),
ARRAY_A ARRAY_A
); );
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! $rows ) { if ( ! $rows ) {
return 0; return 0;
@@ -245,6 +255,21 @@ class TMDO_Zone_Archive {
return count( $entries ); return count( $entries );
} }
/**
* Delete archive rows that copy the same original meta row again, keeping
* the first copy (sweeps before 1.4.2 archived trashed meta every day).
* Rows without an original meta id are left alone.
*
* @return int Rows deleted.
*/
public static function remove_duplicates(): int {
global $wpdb;
$table = self::table();
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
$deleted = $wpdb->query( "DELETE FROM `{$table}` WHERE original_meta_id > 0 AND id NOT IN ( SELECT keep_id FROM ( SELECT MIN(id) AS keep_id FROM `{$table}` WHERE original_meta_id > 0 GROUP BY original_meta_id ) AS k )" );
return is_int( $deleted ) ? $deleted : 0;
}
/** /**
* Get archive statistics. * Get archive statistics.
* *
+23 -5
View File
@@ -25,6 +25,9 @@ if ( ! defined( 'ABSPATH' ) ) {
*/ */
class TMDO_Zone_Cold { class TMDO_Zone_Cold {
/** Option: post type → cold cache version (TMDO_Cache_Layer::flush_group()). */
public const CACHE_VERSION_OPTION = 'wpdo_cold_cache_version';
/** /**
* Get the cold table name for a post type. * Get the cold table name for a post type.
* *
@@ -45,6 +48,21 @@ class TMDO_Zone_Cold {
return 'wpdo_cold_' . sanitize_key( $post_type ); return 'wpdo_cold_' . sanitize_key( $post_type );
} }
/**
* Object-cache key of a post's cold blob. It carries the post type's cache
* version once TMDO_Cache_Layer::flush_group() has bumped it (none before,
* so existing entries keep their keys).
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @return string
*/
public static function cache_key( int $post_id, string $post_type ): string {
$versions = get_option( self::CACHE_VERSION_OPTION, array() );
$version = is_array( $versions ) ? (int) ( $versions[ $post_type ] ?? 0 ) : 0;
return $version ? "cold_{$post_id}_v{$version}" : "cold_{$post_id}";
}
/** /**
* Ensure the cold table exists for a post type. * Ensure the cold table exists for a post type.
* *
@@ -92,7 +110,7 @@ class TMDO_Zone_Cold {
*/ */
public static function get_blob( int $post_id, string $post_type ): array { public static function get_blob( int $post_id, string $post_type ): array {
$group = self::cache_group( $post_type ); $group = self::cache_group( $post_type );
$cache_key = "cold_{$post_id}"; $cache_key = self::cache_key( $post_id, $post_type );
$cached = wp_cache_get( $cache_key, $group ); $cached = wp_cache_get( $cache_key, $group );
if ( false !== $cached ) { if ( false !== $cached ) {
@@ -202,7 +220,7 @@ class TMDO_Zone_Cold {
); );
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
} }
/** /**
@@ -215,7 +233,7 @@ class TMDO_Zone_Cold {
public static function delete( int $post_id, string $post_type ): void { public static function delete( int $post_id, string $post_type ): void {
global $wpdb; global $wpdb;
$wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) ); $wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) );
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
} }
// ── Private helpers ─────────────────────────────────────────────────── // ── Private helpers ───────────────────────────────────────────────────
@@ -264,7 +282,7 @@ class TMDO_Zone_Cold {
); );
// Invalidate object cache. // Invalidate object cache.
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
} }
/** /**
@@ -305,6 +323,6 @@ class TMDO_Zone_Cold {
); );
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) );
} }
} }
+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 * 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 * 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 int $post_id Post ID being deleted.
* @param string $post_type Post type slug. * @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 { public static function delete_post( int $post_id, string $post_type ): void {
$registry = TMDO_Schema_Registry::instance(); $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 { try {
TMDO_Zone_Hot::delete( $post_id, $post_type ); TMDO_Zone_Hot::delete( $post_id, $post_type );
} catch ( \Throwable $e ) { } 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 { try {
TMDO_Zone_Cold::delete( $post_id, $post_type ); TMDO_Zone_Cold::delete( $post_id, $post_type );
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {
+63 -1
View File
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
Requires at least: 6.0 Requires at least: 6.0
Tested up to: 6.9 Tested up to: 6.9
Requires PHP: 8.1 Requires PHP: 8.1
Stable tag: 1.0.0 Stable tag: 1.4.3
License: GPLv2 or later License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -107,6 +107,68 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
== Changelog == == Changelog ==
= 1.4.3 =
* 重新打包(無功能變更):與 WP-MVB v8 樞紐站(dev39)一同驗收後發版;程式碼與前一版相同。
= 1.4.2 =
* The settings page shows why a bridge-mode change was refused (unsafe jump, Promotion Guard) instead of reporting it saved
* Archive sweep skips meta already archived; `wp tmdo install` adds an index on original_meta_id and removes duplicate archive rows (the sweep waits until the index exists)
* Entity health sees a running backfill (batches are scheduled with entity and group args)
* Deactivate and uninstall clear every scheduled event, including ones scheduled with args; uninstall also removes tmdo_* options and transients
* The refused-uninstall notice points to `wp tmdo bridge-restore-native`; the post stress tester no longer uses the retired wc_product table
= 1.4.1 =
* A query ordered by its meta_key among several orderings (or by the key's name) keeps meta_key native; meta_compare_key other than '=' is not routed; a sort group stays when orderby may use it as the primary clause
* Only standalone posts.ID terms in ORDER BY become flat post_id tie-breaks (FIELD()/IN expressions from other plugins are left alone); PCRE failures keep the original SQL
* Router query vars preset by callers are dropped
* New posts' flat rows get their status/date copies; post-status-sync invalidates cached routed counts
= 1.4.0 =
* Flat-first sorts read the ID tie-break from the flat post_id, so a declared (column, post_id) index returns rows in order; ties now follow the sort direction
* Meta clauses without a value route as EXISTS; HivePress's featured-listing lookup on every search page uses the flat table
* Date-ordered routed searches drop the redundant GROUP BY and use a (post_status, post_date, post_id) index (replace the index on the tables listed in `tmdo_post_status_columns`; `wp tmdo post-status-sync` also adds the copy columns to every other post group)
* Rows rebuilt by the migration engine keep their status/date copies and invalidate cached routed counts
* Live test tools no longer flush the object cache
= 1.3.0 =
* Declared flat-table indexes are added only from WP-CLI or WP-Cron, one process at a time (MySQL GET_LOCK); web requests schedule `tmdo_ensure_group_indexes`, and doctor lists indexes not added yet
* Routed searches with post__not_in count from a cached base count minus the excluded posts; the cache key ignores wpdb's per-request placeholder escape and follows post saves and managed-key writes only, not every post meta write (HivePress keyword + price search no longer slower than native)
* Rate-limited view requests sample their statistics (1 in 10) instead of writing an option each time
* Logger info / notice lines only with WP_DEBUG_LOG (filter `tmdo_log_info`)
* The public listings REST output withholds email, phone, address and IP fields even when declared public
* Admin form posts are handled before the page renders; uninstall.php globals are prefixed
* Existing flat tables are upgraded from WP-CLI or WP-Cron only; a group waiting for its upgrade uses native meta, then a drift fix rebuilds it
* The index lock goes to the primary under read/write splitting; clearing the cold cache bumps a version instead of scanning the object cache group
* Groups whose flat table was dropped no longer cause database errors on post status changes
* Schema upgrades share one MySQL lock with index DDL; new or upgraded groups stay native until a drift fix has filled them; mode changes and snapshot deletions are always logged
= 1.2.0 =
* `wp tmdo drift-check <entity> [--group] [--sample] [--fix]`: reports flat-vs-native drift per key (miss / diff / extra) and rebuilds drifted flat rows from native; native meta is never written, and aeav_only sites are refused
* The post query router keeps a query native while one of its groups is recorded as drifting; a daily sample (500 entities per group) on the existing health-check event keeps the record current
* Switching an entity from disabled to dual_write / shadow_read, or turning the Hook Bus back on, schedules a full drift fix 60 s later (`tmdo_drift_resync`)
* Auto-promotion requires a clean drift check within 2 days; shadow_read diffs are capped at 20 per key per hour
* `wp tmdo bridge-mode <entity> <mode>` (under the Promotion Guard); `member-backfill --all`; post / user backfill groups come from the registry, json groups included
* WooCommerce: the `post/wc_product` group is no longer registered — product meta stays native (retire existing tables with `bridge-restore-native post --group=wc_product --retire`)
* Sort-only searches drive from the flat table when every post of the type has a flat row (HivePress rating sort ~3.3 s → ~0.5–0.85 s on 100k listings)
* `wp tmdo cleanup-hp-transients --options` removes HivePress AddOn transients left in wp_options
* Fixed: entity backfill invented default values (0) for keys with no native row; doctor, term-comment-diagnose and bridge-status false alarms; member-cutover fatal on a refused transition
* Admin output escaping and logging clean-ups from the 2.1 audit
= 1.1.1 =
* 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 = = 1.0.0 =
* Supersedes `wp-data-optimizer` v3.4.6 — that plugin is retired; this one is the single unified engine * 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 * Version numbering restarts at 1.0.0; entries below 3.4.6 are the predecessor's history, kept for archaeology
+11 -5
View File
@@ -180,7 +180,7 @@ if ( ! function_exists( 'do_action_ref_array' ) ) {
} }
} }
if ( ! function_exists( 'wp_next_scheduled' ) ) { if ( ! function_exists( 'wp_next_scheduled' ) ) {
function wp_next_scheduled( string $hook ): int|false { return false; } function wp_next_scheduled( string $hook, array $args = array() ): int|false { return $GLOBALS['_wp_next_scheduled'][ $hook ][ md5( serialize( $args ) ) ] ?? false; }
} }
if ( ! function_exists( 'wp_schedule_event' ) ) { if ( ! function_exists( 'wp_schedule_event' ) ) {
function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } function wp_schedule_event( int $t, string $r, string $h ): bool { return true; }
@@ -188,6 +188,7 @@ if ( ! function_exists( 'wp_schedule_event' ) ) {
if ( ! function_exists( 'wp_schedule_single_event' ) ) { if ( ! function_exists( 'wp_schedule_single_event' ) ) {
function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } function wp_schedule_single_event( int $ts, string $hook ): bool { return true; }
} }
if ( ! function_exists( 'wp_unschedule_hook' ) ) { function wp_unschedule_hook( string $hook ): int|false { $GLOBALS['_wp_unscheduled'][] = $hook; return 0; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) {
function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } function wp_clear_scheduled_hook( string $hook ): int|false { return 0; }
} }
@@ -506,10 +507,12 @@ if ( ! function_exists( 'register_rest_route' ) ) {
// WP_CLI stub (needed before loading CLI classes). // WP_CLI stub (needed before loading CLI classes).
if ( ! class_exists( 'WP_CLI' ) ) { if ( ! class_exists( 'WP_CLI' ) ) {
class WP_CLI { class WP_CLI {
public static function log( string $msg ): void {} // Output is kept in $GLOBALS['_wp_cli_output'] so CLI tests can assert it.
public static function warning( string $msg ): void {} public static function log( string $msg ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
public static function success( string $msg ): void {} public static function line( string $msg = '' ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
public static function error( string $msg ): void {} public static function warning( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Warning: ' . $msg; }
public static function success( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Success: ' . $msg; }
public static function error( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Error: ' . $msg; }
public static function add_command( string $name, $class ): void {} public static function add_command( string $name, $class ): void {}
} }
} }
@@ -613,6 +616,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// Engine (v2.0.0). // Engine (v2.0.0).
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.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-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-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-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'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -621,6 +625,7 @@ require_once TMDO_PATH . 'includes/engine/class-tmdo-query-compiler.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php';
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php'; require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php'; require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
@@ -646,6 +651,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
// Options Manager. // Options Manager.
@@ -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,58 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* `wp tmdo cleanup-hp-transients --options` (2.2 PR-080): the HivePress AddOn
* moved HivePress's per-post transients out of wp_postmeta into wp_options
* (`_transient_wpdo_hp_pm_*`), where they piled up — 1.15 M rows on dev36. Since
* PR-081 they live in the object cache when there is one; this removes the
* rows left behind. Works without the AddOn loaded (as in core CI).
*/
class CleanupHpTransientsTest extends TestCase {
private const OPTIONS = 'wp_itest_options';
public static function setUpBeforeClass(): void {
require_once WPDO_PLUGIN_DIR . 'cli/class-tmdo-cli-post.php';
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::OPTIONS . '`' );
$wpdb->query( 'CREATE TABLE `' . self::OPTIONS . "` ( option_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, option_name VARCHAR(191) NOT NULL DEFAULT '', option_value LONGTEXT NOT NULL, autoload VARCHAR(20) NOT NULL DEFAULT 'yes', PRIMARY KEY (option_id), UNIQUE KEY option_name (option_name) ) ENGINE=InnoDB" );
for ( $i = 1; $i <= 5; $i++ ) {
$wpdb->insert( self::OPTIONS, array( 'option_name' => "_transient_wpdo_hp_pm_{$i}_abc", 'option_value' => 'v', 'autoload' => 'no' ) );
$wpdb->insert( self::OPTIONS, array( 'option_name' => "_transient_timeout_wpdo_hp_pm_{$i}_abc", 'option_value' => '1', 'autoload' => 'no' ) );
}
foreach ( array( 'siteurl', '_transient_other_plugin', '_transient_wpdo_hp_pmx', 'wpdo_hp_pm_1_abc' ) as $name ) {
$wpdb->insert( self::OPTIONS, array( 'option_name' => $name, 'option_value' => 'keep' ) );
}
$GLOBALS['_wp_cli_output'] = array();
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::OPTIONS . '`' );
}
private function remaining(): array {
global $wpdb;
return $wpdb->get_col( 'SELECT option_name FROM `' . self::OPTIONS . '` ORDER BY option_name' );
}
public function test_dry_run_counts_the_rows_and_deletes_nothing(): void {
( new TMDO_CLI_Post() )->cleanup_hp_transients( array(), array( 'options' => true, 'dry-run' => true ) );
$this->assertCount( 14, $this->remaining() );
$this->assertStringContainsString( '10 HivePress transient row(s) in wp_options', implode( "\n", $GLOBALS['_wp_cli_output'] ) );
}
public function test_confirm_deletes_only_the_hivepress_rows_in_batches(): void {
( new TMDO_CLI_Post() )->cleanup_hp_transients( array(), array( 'options' => true, 'confirm' => true, 'batch' => '3' ) );
$this->assertSame( array( '_transient_other_plugin', '_transient_wpdo_hp_pmx', 'siteurl', 'wpdo_hp_pm_1_abc' ), $this->remaining() );
$this->assertStringContainsString( 'Deleted 10 HivePress transient row(s) from wp_options', implode( "\n", $GLOBALS['_wp_cli_output'] ) );
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* `wp tmdo bridge-status` progress (2.2 PR-076): eav_ids of a post group that
* belongs to some post types counted every post holding one of its keys, so a
* fully backfilled group showed less than 100%.
*
* flat_rows counted rows without any value too (wp_core holds an all-NULL row
* for every post): dev36 showed wp_core at 15306%.
*
* Fixture: group bs_grp for hp_listing only; bs_price on post 1 (hp_listing)
* and post 2 (a plain post); flat rows for post 1 (a value) and post 3
* (hp_listing, all NULL).
*/
class CliBridgeStatusTest extends TestCase {
private const POSTS = 'wp_itest_posts';
private const POSTMETA = 'wp_itest_postmeta';
private const FLAT = 'wp_itest_wpdo_post_bs_grp';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-post.php',
'cli/class-tmdo-cli-v2.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
if ( ! function_exists( 'WP_CLI\Utils\format_items' ) ) {
eval( 'namespace WP_CLI\Utils; function format_items( $format, $items, $fields ) { $GLOBALS["_wp_cli_items"] = $items; }' ); // phpcs:ignore Squiz.PHP.Eval -- test-only WP-CLI stub.
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( 'CREATE TABLE `' . self::POSTS . "` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_type VARCHAR(20) NOT NULL DEFAULT 'post', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::POSTMETA . '` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::FLAT . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, bs_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
$wpdb->insert( self::POSTS, array( 'ID' => 1, 'post_type' => 'hp_listing' ) );
$wpdb->insert( self::POSTS, array( 'ID' => 2, 'post_type' => 'post' ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => 1, 'meta_key' => 'bs_price', 'meta_value' => '10' ) );
$wpdb->insert( self::POSTMETA, array( 'post_id' => 2, 'meta_key' => 'bs_price', 'meta_value' => '20' ) );
$wpdb->insert( self::FLAT, array( 'post_id' => 1, 'bs_price' => 10 ) );
$wpdb->insert( self::POSTS, array( 'ID' => 3, 'post_type' => 'hp_listing' ) );
$wpdb->insert( self::FLAT, array( 'post_id' => 3 ) );
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'post' => 'dual_write' ) );
$GLOBALS['_wp_cli_items'] = array();
$GLOBALS['_wp_cli_output'] = array();
TMDO_Mode_Manager::reset_cache();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group( 'post', 'bs_grp', array( array( 'key' => 'bs_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
TMDO_Mode_Manager::reset_cache();
}
private static function drop(): void {
global $wpdb;
foreach ( array( self::POSTS, self::POSTMETA, self::FLAT ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
public function test_counts_only_flat_rows_that_hold_a_value_and_posts_of_the_groups_types(): void {
( new TMDO_CLI_V2() )->bridge_status( array(), array() );
$row = array_values( array_filter( $GLOBALS['_wp_cli_items'], static fn( $r ) => 'bs_grp' === $r['group'] ) )[0];
$this->assertSame( 1, $row['flat_rows'] );
$this->assertSame( 1, $row['eav_ids'] );
$this->assertSame( '100%', $row['progress_%'] );
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* `wp tmdo member-backfill` takes its groups from the registry (2.2 PR-068).
*
* Before: a hard-coded VALID_GROUPS list (with the never-registered `sso`)
* rejected groups registered by add-ons, and there was no way to backfill
* every user group at once — R-2.1 had to fall back to `wp eval`.
*
* Fixture (user entity): groups cb_one (cb_a) and cb_two (cb_b).
*/
class CliMemberBackfillTest extends TestCase {
private const ONE = 'wp_itest_wpdo_user_cb_one';
private const TWO = 'wp_itest_wpdo_user_cb_two';
private const STATUS = 'wp_itest_wpdo_migration_status';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-user.php',
'cli/class-tmdo-cli-member.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::ONE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::TWO . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_b VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_a', 'meta_value' => 'one' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_b', 'meta_value' => 'two' ) );
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', 'cb_one', array( array( 'key' => 'cb_a', 'type' => 'text' ) ) );
WPDO_Entity_Registry::register_group( 'user', 'cb_two', array( array( 'key' => 'cb_b', 'type' => 'text' ) ) );
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->usermeta, self::ONE, self::TWO, self::STATUS ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function flat( string $table, string $col ): ?string {
global $wpdb;
return $wpdb->get_var( "SELECT `{$col}` FROM `{$table}` WHERE user_id = 1" );
}
public function test_all_backfills_every_registered_user_group(): void {
( new TMDO_CLI_Member() )->member_backfill( array(), array( 'all' => true ) );
$this->assertSame( 'one', $this->flat( self::ONE, 'cb_a' ) );
$this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) );
}
public function test_group_accepts_any_registered_user_group(): void {
( new TMDO_CLI_Member() )->member_backfill( array(), array( 'group' => 'cb_two' ) );
$this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) );
$this->assertNull( $this->flat( self::ONE, 'cb_a' ), 'only the requested group' );
}
public function test_backfillable_groups_leave_out_restored_orphans(): void {
WPDO_Entity_Registry::restore_group( 'user', 'cb_orphan', array( array( 'key' => 'cb_orphan_key', 'type' => 'text' ) ) );
$this->assertSame( array( 'cb_one', 'cb_two' ), TMDO_Entity_Migration_Engine::backfillable_groups( 'user' ) );
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* `wp tmdo doctor` false alarms (2.2 PR-076).
*
* The user flat tables were a hard-coded list including wpdo_user_sso, a group
* never registered: doctor reported it MISSING and failed on every site. The
* tables now come from the registry; doctor also reports Promotion Guard
* violations and the recorded drift status.
*/
class DoctorTest extends TestCase {
private const HAVE = 'wp_itest_wpdo_user_dt_have';
private const CORE = array( 'wp_itest_wpdo_migration_status', 'wp_itest_wpdo_user_points_ledger' );
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/engine/class-tmdo-promotion-guard.php',
'cli/class-tmdo-cli.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
foreach ( array_merge( array( self::HAVE ), self::CORE ) as $table ) {
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINE=InnoDB" );
}
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cli_output'] = array();
TMDO_Mode_Manager::reset_cache();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', 'dt_have', array( array( 'key' => 'dt_a', 'type' => 'text' ) ) );
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
TMDO_Mode_Manager::reset_cache();
}
private static function drop(): void {
global $wpdb;
foreach ( array_merge( array( self::HAVE ), self::CORE ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function cli_output(): string {
return implode( "\n", $GLOBALS['_wp_cli_output'] ?? array() );
}
public function test_entity_tables_come_from_the_registry(): void {
$ok = TMDO_CLI::check_entity_tables();
$this->assertTrue( $ok, $this->cli_output() );
$this->assertStringContainsString( '[OK] ' . self::HAVE, $this->cli_output() );
$this->assertStringNotContainsString( 'wpdo_user_sso', $this->cli_output() );
}
public function test_a_registered_group_without_its_table_is_reported(): void {
WPDO_Entity_Registry::register_group( 'user', 'dt_missing', array( array( 'key' => 'dt_b', 'type' => 'text' ) ) );
$ok = TMDO_CLI::check_entity_tables();
$this->assertFalse( $ok );
$this->assertStringContainsString( '[MISSING] wp_itest_wpdo_user_dt_missing', $this->cli_output() );
}
public function test_reports_drifting_groups(): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ] = array(
'user' => array(
'dt_have' => array(
'miss' => 1,
'diff' => 2,
'extra' => 0,
'sample' => 0,
'checked_at' => '2026-09-27 00:00:00',
),
),
);
TMDO_Mode_Manager::reset_cache();
TMDO_CLI::report_guard_and_drift();
$this->assertStringContainsString( 'dt_have miss=1 diff=2 extra=0', $this->cli_output() );
$this->assertStringContainsString( 'wp tmdo drift-check user --fix', $this->cli_output() );
}
public function test_ignores_the_record_of_a_group_no_longer_registered(): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ] = array(
'user' => array(
'dt_have' => array(
'miss' => 0,
'diff' => 0,
'extra' => 0,
'sample' => 0,
'checked_at' => '2026-09-27 00:00:00',
),
'dt_gone' => array(
'miss' => 3,
'diff' => 0,
'extra' => 0,
'sample' => 0,
'checked_at' => '2026-09-20 00:00:00',
),
),
);
TMDO_Mode_Manager::reset_cache();
TMDO_CLI::report_guard_and_drift();
$this->assertStringContainsString( '[OK] user: 1 group(s) clean (oldest check 2026-09-27 00:00:00 UTC)', $this->cli_output() );
$this->assertStringNotContainsString( 'dt_gone', $this->cli_output() );
}
public function test_reports_an_entity_never_checked(): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => 'dual_write' );
TMDO_Mode_Manager::reset_cache();
TMDO_CLI::report_guard_and_drift();
$this->assertStringContainsString( 'user: never checked', $this->cli_output() );
$this->assertStringContainsString( 'Promotion Guard: no violation', $this->cli_output() );
}
}
+320
View File
@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* TMDO_Drift_Check (2.2 PR-070): read-only flat-vs-native comparison.
*
* Fixture (user entity, group dc_grp):
* user 1 every key equal in value, differently formatted
* (50 vs 50.000000, serialized array vs JSON, required flag holding
* its default with no native row) → clean
* user 2 name 'b' vs 'B', cnt 5 vs 6 → diff ×2
* user 3 native name, flat row with name NULL → miss
* user 4 no native rows, flat cnt 9 → extra
* user 5 native name '', flat NULL → clean
* user 6 native name longer than a text column, flat NULL → clean
* user 7 native cnt, no flat row → miss
*/
class DriftCheckTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_dc_grp';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/diagnostic/class-tmdo-drift-check.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->users}` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_login VARCHAR(60) NOT NULL DEFAULT '', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, num DECIMAL(18,6) DEFAULT NULL, cnt BIGINT(20) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, prefs TEXT DEFAULT NULL, meta_json LONGTEXT DEFAULT NULL, flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
for ( $id = 1; $id <= 7; $id++ ) {
$wpdb->insert( $wpdb->users, array( 'ID' => $id, 'user_login' => "u{$id}" ) );
}
$prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) );
$this->native( 1, array( 'num' => '50', 'cnt' => '3', 'name' => 'a', 'prefs' => $prefs, 'meta_json' => serialize( array( 'w' => 640, 'tags' => array( 'x' ) ) ) ) );
$this->flat( array( 'user_id' => 1, 'num' => '50.000000', 'cnt' => 3, 'name' => 'a', 'prefs' => $prefs, 'meta_json' => '{"w":640,"tags":["x"]}' ) );
$this->native( 2, array( 'name' => 'b', 'cnt' => '5' ) );
$this->flat( array( 'user_id' => 2, 'name' => 'B', 'cnt' => 6 ) );
$this->native( 3, array( 'name' => 'c' ) );
$this->flat( array( 'user_id' => 3, 'cnt' => null ) );
$this->flat( array( 'user_id' => 4, 'cnt' => 9 ) );
$this->native( 5, array( 'name' => '' ) );
$this->flat( array( 'user_id' => 5 ) );
$this->native( 6, array( 'name' => str_repeat( 'x', 300 ) ) );
$this->flat( array( 'user_id' => 6 ) );
$this->native( 7, array( 'cnt' => '7' ) );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group(
'user',
'dc_grp',
array(
array( 'key' => 'num', 'type' => 'decimal' ),
array( 'key' => 'cnt', 'type' => 'integer' ),
array( 'key' => 'name', 'type' => 'text' ),
array( 'key' => 'prefs', 'type' => 'textarea' ),
array( 'key' => 'meta_json', 'type' => 'json' ),
array( 'key' => 'flag', 'type' => 'integer', 'default' => 0, 'required' => true ),
)
);
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->users, $wpdb->usermeta, self::TABLE ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function native( int $user_id, array $values ): void {
global $wpdb;
foreach ( $values as $key => $value ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) );
}
}
private function flat( array $row ): void {
global $wpdb;
$wpdb->insert( self::TABLE, $row );
}
private function snapshot(): string {
global $wpdb;
return md5( wp_json_encode( array( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ), $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) );
}
public function test_counts_miss_diff_and_extra_per_key(): void {
$report = TMDO_Drift_Check::run( 'user' );
$this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] );
$keys = $report['groups']['dc_grp']['keys'];
$this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 0 ), $keys['name'] );
$this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 1 ), $keys['cnt'] );
foreach ( array( 'num', 'prefs', 'meta_json', 'flag' ) as $key ) {
$this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $keys[ $key ], "{$key}: equal by value" );
}
}
public function test_records_the_outcome_per_group(): void {
TMDO_Drift_Check::run( 'user', 'dc_grp' );
$status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp'];
$this->assertSame( 2, $status['miss'] );
$this->assertSame( 2, $status['diff'] );
$this->assertSame( 1, $status['extra'] );
$this->assertSame( 0, $status['sample'] );
$this->assertNotEmpty( $status['checked_at'] );
}
public function test_a_sample_covering_every_user_matches_the_full_check(): void {
$report = TMDO_Drift_Check::run( 'user', '', 100 );
$this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] );
$this->assertSame( 100, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['sample'] );
}
public function test_never_writes_native_or_flat(): void {
$before = $this->snapshot();
TMDO_Drift_Check::run( 'user' );
$this->assertSame( $before, $this->snapshot() );
}
// ── fix() (2.2 PR-071) ──────────────────────────────────────────────────
private function flat_row( int $user_id ): ?array {
global $wpdb;
return $wpdb->get_row( $wpdb->prepare( 'SELECT num, cnt, name, flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A );
}
public function test_fix_rebuilds_drifted_rows_from_native_and_leaves_no_drift(): void {
$result = TMDO_Drift_Check::fix( 'user' );
$this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $result['after']['total'] );
$this->assertSame( 'b', $this->flat_row( 2 )['name'] );
$this->assertSame( '5', $this->flat_row( 2 )['cnt'] );
$this->assertSame( 'c', $this->flat_row( 3 )['name'] );
$this->assertSame( array( 'num' => null, 'cnt' => '7', 'name' => null, 'flag' => '0' ), $this->flat_row( 7 ), 'new row: no invented defaults' );
$this->assertSame( array( 'num' => null, 'cnt' => null, 'name' => null, 'flag' => '0' ), $this->flat_row( 4 ), 'no native values: row kept, columns cleared' );
$this->assertSame( 0, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['diff'] );
}
public function test_fix_deletes_rows_of_entities_that_no_longer_exist(): void {
$this->flat( array( 'user_id' => 99, 'name' => 'ghost' ) );
$result = TMDO_Drift_Check::fix( 'user' );
$this->assertSame( 1, $result['fixed']['deleted_rows'] );
$this->assertNull( $this->flat_row( 99 ) );
$this->assertNotNull( $this->flat_row( 1 ), 'rows of existing users stay' );
}
public function test_fix_never_writes_native_and_invalidates_the_row_cache(): void {
global $wpdb;
$native = md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) );
TMDO_Cache_Orchestrator::set_row( 'user', 2, 'dc_grp', array( 'name' => 'B' ) );
TMDO_Drift_Check::fix( 'user' );
$this->assertSame( $native, md5( wp_json_encode( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ) ) ) );
$this->assertFalse( TMDO_Cache_Orchestrator::get_row( 'user', 2, 'dc_grp' ) );
}
// ── cron_tick() (2.2 PR-073) ────────────────────────────────────────────
private static function set_modes( ?array $modes ): void {
$cache = ( new ReflectionClass( WPDO_Mode_Manager::class ) )->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, $modes );
}
public function test_the_daily_tick_samples_entities_that_write_to_flat(): void {
self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::cron_tick();
} finally {
self::set_modes( null );
}
$status = get_option( TMDO_Drift_Check::OPTION );
$this->assertSame( TMDO_Drift_Check::CRON_SAMPLE, $status['user']['dc_grp']['sample'] );
$this->assertSame( 2, $status['user']['dc_grp']['diff'], 'the sample covers all 7 fixture users' );
$this->assertArrayNotHasKey( 'post', $status );
}
public function test_the_daily_tick_skips_entities_that_do_not_write_to_flat(): void {
self::set_modes( array( 'post' => 'disabled', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::cron_tick();
} finally {
self::set_modes( null );
}
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
}
public function test_the_daily_tick_skips_aeav_only_where_flat_is_the_source_of_truth(): void {
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::cron_tick();
} finally {
self::set_modes( null );
}
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
}
public function test_fix_refuses_aeav_only_and_leaves_flat_alone(): void {
global $wpdb;
$flat = md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) );
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::fix( 'user' );
$this->fail( 'fix() must refuse aeav_only' );
} catch ( RuntimeException $e ) {
$this->assertStringContainsString( 'aeav_only', $e->getMessage() );
} finally {
self::set_modes( null );
}
$this->assertSame( $flat, md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) );
}
// ── resync after writes bypassed flat (2.2 PR-074) ──────────────────────
public function test_turning_writes_on_schedules_a_fix_for_that_entity(): void {
$this->assertSame( array( 'user' ), TMDO_Drift_Check::resync_after_mode_change( 'user', 'dual_write', 'disabled' ) );
}
public function test_other_mode_changes_schedule_nothing(): void {
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'shadow_read', 'dual_write' ) );
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'aeav_only', 'shadow_read' ) );
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'disabled', 'dual_write' ) );
}
public function test_turning_the_hook_bus_on_schedules_a_fix_for_entities_that_keep_native(): void {
self::set_modes( array( 'post' => 'aeav_only', 'user' => 'dual_write', 'term' => 'shadow_read', 'comment' => 'disabled' ) );
try {
$on = TMDO_Drift_Check::resync_after_hook_bus_change( '0', '1' );
$off = TMDO_Drift_Check::resync_after_hook_bus_change( '1', '0' );
} finally {
self::set_modes( null );
}
$this->assertSame( array( 'user', 'term' ), $on );
$this->assertSame( array(), $off );
}
public function test_the_scheduled_fix_repairs_the_entity(): void {
self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::run_scheduled_fix( 'user' );
} finally {
self::set_modes( null );
}
$status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp'];
$this->assertSame( 0, $status['miss'] + $status['diff'] + $status['extra'] );
}
public function test_the_scheduled_fix_leaves_aeav_only_alone(): void {
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
try {
TMDO_Drift_Check::run_scheduled_fix( 'user' );
} finally {
self::set_modes( null );
}
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
$this->assertSame( 'B', $this->flat_row( 2 )['name'], 'flat untouched' );
}
public function test_the_resync_triggers_are_hooked(): void {
$source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' );
foreach ( array(
"add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );",
"add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );",
"add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );",
"add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );",
) as $line ) {
$this->assertStringContainsString( $line, $source );
}
// Literal: uninstall.php does not load TMDO_Drift_Check.
$installer = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php' );
// One CRON_HOOKS list serves deactivate and uninstall (InstallerCleanupTest covers both).
$this->assertSame( 1, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'listed in CRON_HOOKS' );
}
public function test_the_daily_tick_runs_on_the_daily_health_check_hook(): void {
$source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' );
$this->assertStringContainsString( "add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );", $source );
}
public function test_rejects_an_unknown_group(): void {
$this->expectException( InvalidArgumentException::class );
TMDO_Drift_Check::run( 'user', 'no_such_group' );
}
}
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* TMDO_Entity_Migration_Engine::migrate_group() must not invent values for keys
* the object has no native row for.
*
* Bug (dev36 R-2.1, 2026-09-26): the engine REPLACEs only the columns it found
* native rows for, so every other column fell back to its DDL default —
* user activity.session_count / account_flags (nullable, DEFAULT 0) became 0
* for every backfilled user, and the checker's "extra" count went 60 → 123.
*
* Fixture (user entity, group md_act):
* login_count integer, default 0, nullable
* session_count integer, default 0, nullable
* note text
* req_flag integer, required (NOT NULL DEFAULT 0)
*/
class EntityMigrationEngineDefaultsTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_md_act';
private const STATUS = 'wp_itest_wpdo_migration_status';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-entity-migration-engine.php',
'includes/adapters/class-tmdo-adapter-user.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, login_count BIGINT(20) DEFAULT 0, session_count BIGINT(20) DEFAULT 0, note VARCHAR(255) DEFAULT NULL, req_flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group(
'user',
'md_act',
array(
array( 'key' => 'login_count', 'type' => 'integer', 'default' => 0 ),
array( 'key' => 'session_count', 'type' => 'integer', 'default' => 0 ),
array( 'key' => 'note', 'type' => 'text' ),
array( 'key' => 'req_flag', 'type' => 'integer', 'default' => 0, 'required' => true ),
)
);
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->usermeta, self::TABLE, self::STATUS ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function native( int $user_id, string $key, string $value ): void {
global $wpdb;
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) );
}
/** @return array<string, string|null> */
private function flat_row( int $user_id ): array {
global $wpdb;
return (array) $wpdb->get_row( $wpdb->prepare( 'SELECT login_count, session_count, note, req_flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A );
}
private function migrate(): array {
return TMDO_Entity_Migration_Engine::migrate_group( 'user', 'md_act', array( 'resume' => false, 'sleep_ms' => 0 ) );
}
public function test_keys_without_a_native_row_stay_null_instead_of_the_column_default(): void {
$this->native( 1, 'login_count', '7' );
$report = $this->migrate();
$this->assertSame( 1, $report['migrated'] );
$row = $this->flat_row( 1 );
$this->assertSame( '7', $row['login_count'] );
$this->assertNull( $row['session_count'], 'no native session_count — must not become the DEFAULT 0' );
$this->assertNull( $row['note'] );
}
public function test_a_native_value_equal_to_the_default_is_still_written(): void {
$this->native( 1, 'session_count', '0' );
$this->migrate();
$this->assertSame( '0', $this->flat_row( 1 )['session_count'] );
}
public function test_a_stale_flat_value_is_cleared_when_the_native_row_is_gone(): void {
global $wpdb;
$wpdb->insert( self::TABLE, array( 'user_id' => 1, 'login_count' => 3, 'session_count' => 5, 'note' => 'old' ) );
$this->native( 1, 'login_count', '4' );
$this->migrate();
$row = $this->flat_row( 1 );
$this->assertSame( '4', $row['login_count'] );
$this->assertNull( $row['session_count'] );
$this->assertNull( $row['note'] );
}
public function test_a_required_column_without_a_native_row_keeps_its_default(): void {
$this->native( 1, 'login_count', '1' );
$report = $this->migrate();
$this->assertSame( 0, $report['errors'] );
$this->assertSame( '0', $this->flat_row( 1 )['req_flag'], 'NOT NULL column cannot hold NULL — its default is the documented stand-in' );
}
public function test_the_cron_batch_path_does_not_invent_defaults_either(): void {
$this->native( 1, 'login_count', '2' );
$result = TMDO_Entity_Migration_Engine::migrate_group_batch( 'user', 'md_act' );
$this->assertSame( 1, $result['migrated'] );
$this->assertNull( $this->flat_row( 1 )['session_count'] );
}
}
@@ -31,6 +31,10 @@ class GroupCompositeIndexTest extends TestCase {
$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( '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)' ); $wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' );
$GLOBALS['_wp_options'] = array(); $GLOBALS['_wp_options'] = array();
// Index DDL runs from WP-CLI / WP-Cron only (2.3 PR-090a); these tests act as one.
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true );
$GLOBALS['_wp_scheduled_single'] = array();
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true ); $ref->setAccessible( true );
@@ -53,6 +57,120 @@ class GroupCompositeIndexTest extends TestCase {
global $wpdb; global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
WPDO_Entity_Registry::init(); WPDO_Entity_Registry::init();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
unset( $GLOBALS['_wp_is_admin'], $GLOBALS['_wp_scheduled_single'] );
}
public function test_a_web_request_adds_no_index_and_leaves_it_to_wp_cron(): void {
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
foreach ( array( false, true ) as $is_admin ) { // /wp-admin/ runs init before auth_redirect().
$GLOBALS['_wp_is_admin'] = $is_admin;
$this->assertFalse( TMDO_Schema_Manager::ddl_allowed() );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'no ALTER in a web request' );
}
$this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() );
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // The cron run.
TMDO_Schema_Manager::ensure_pending_indexes();
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
$this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() );
}
public function test_the_ddl_decision_can_be_overridden(): void {
add_filter( 'tmdo_schema_ddl_allowed', static fn() => false );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes() );
}
public function test_a_failed_alter_is_not_recorded_as_applied(): void {
global $wpdb;
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` ADD COLUMN ci_note TEXT DEFAULT NULL' );
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_note', 'type' => 'textarea' ),
),
array( 'indexes' => array( 'flag_note' => array( 'ci_flag', 'ci_note' ) ) ) // TEXT without a prefix length: MySQL 1170.
);
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_note', $this->indexes() );
$this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ), 'not recorded, so it is retried' );
$this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() );
}
public function test_a_lock_held_by_another_process_skips_the_ddl_until_it_is_released(): void {
$other = new mysqli(
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
(string) getenv( 'TMDO_TEST_DB_PASS' ),
getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test'
);
$name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() );
$this->assertSame( '1', (string) $other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" )->fetch_row()[0] );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'another process holds the lock' );
$other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" );
$other->close();
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
$this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() );
global $wpdb;
$this->assertSame( '1', (string) $wpdb->get_var( $wpdb->prepare( 'SELECT IS_FREE_LOCK(MD5(CONCAT(DATABASE(), %s)))', TMDO_Schema_Manager::index_lock_name() ) ), 'lock released' );
}
/**
* Under read/write splitting (HyperDB, LudicrousDB) a SELECT may go to a
* replica, where GET_LOCK would lock nothing: reads go to the primary first.
*/
public function test_the_lock_is_taken_on_the_primary_under_read_write_splitting(): void {
global $wpdb;
$real = $wpdb;
$wpdb = new class( $real ) {
/** @var string[] */
public array $calls = array();
public function __construct( private object $db ) {}
public function send_reads_to_masters(): void {
$this->calls[] = 'send_reads_to_masters';
}
public function __call( string $name, array $args ) {
if ( 'get_var' === $name && str_contains( (string) $args[0], 'GET_LOCK' ) ) {
$this->calls[] = 'GET_LOCK';
}
return $this->db->$name( ...$args );
}
public function __get( string $name ) {
return $this->db->$name;
}
};
try {
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$calls = $wpdb->calls;
} finally {
$wpdb = $real;
}
$this->assertSame( array( 'send_reads_to_masters', 'GET_LOCK' ), $calls );
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
} }
/** @return array<string, string[]> index name → ordered columns */ /** @return array<string, string[]> index name → ordered columns */
+125 -2
View File
@@ -202,14 +202,113 @@ class HookBusIntegrationTest extends TestCase {
$this->assertTrue( $result, 'aeav_only must return true to short-circuit EAV write' ); $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 ); self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
$GLOBALS['_wp_current_filter'] = 'update_user_metadata'; $GLOBALS['_wp_current_filter'] = 'update_user_metadata';
$result = WPDO_Hook_Bus::intercept_update( null, 3, 'first_name', 'Carol', '' ); $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( $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 ) );
}
);
}
/**
* A group whose table upgrade waits for WP-CLI / WP-Cron (2.3 PR-107)
* stays native: its table may lack the new columns.
*/
public function test_a_group_waiting_for_its_table_upgrade_stays_native(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/core_profile' => time() ) );
try {
$this->with_usermeta(
function () use ( $wpdb ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'first_name', 'meta_value' => 'Dora' ) );
WPDO_Hook_Bus::sync_from_native( 'user', 4, 'first_name', 'Dora', 'update' );
$this->assertNull( $this->read_flat_first_name( 4 ) );
$this->assertSame( 'Dora', WPDO_Hook_Bus::direct_read( 'user', 4, 'first_name' ), 'direct reads return the native value' );
}
);
} finally {
delete_option( TMDO_Schema_Manager::DEFERRED_OPTION );
}
}
/**
* 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 { public function test_update_passthrough_for_unregistered_key(): void {
@@ -268,6 +367,30 @@ class HookBusIntegrationTest extends TestCase {
$this->assertNull( $result, 'dual_write does not read from flat — must return $check' ); $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 { public function test_get_returns_empty_string_when_column_is_null(): void {
global $wpdb; global $wpdb;
$wpdb->query( $wpdb->query(
@@ -68,6 +68,50 @@ class InstallerCleanupTest extends TestCase {
$GLOBALS['_wp_options'] = array(); $GLOBALS['_wp_options'] = array();
} }
/**
* Events scheduled with args (tmdo_drift_resync( 'post' ), backfill
* batches) are not cleared by wp_clear_scheduled_hook( $hook ); the user
* stress hook was misnamed and three hooks were missing (2.4.2 PR-138, F-4).
*/
private const ARG_AND_MISSED_HOOKS = array( 'tmdo_drift_resync', 'wpdo_entity_backfill_batch', 'wpdo_stress_test_batch', 'wpdo_migration_tick', 'wpdo_auto_promote_check' );
public function test_deactivate_unschedules_every_hook_whatever_its_args(): void {
$GLOBALS['_wp_unscheduled'] = array();
WPDO_Installer::deactivate();
foreach ( self::ARG_AND_MISSED_HOOKS as $hook ) {
$this->assertContains( $hook, $GLOBALS['_wp_unscheduled'], $hook );
}
}
public function test_uninstall_unschedules_every_hook_whatever_its_args(): void {
$GLOBALS['_wp_unscheduled'] = array();
WPDO_Installer::drop_all_tables_for_current_blog();
foreach ( self::ARG_AND_MISSED_HOOKS as $hook ) {
$this->assertContains( $hook, $GLOBALS['_wp_unscheduled'], $hook );
}
}
/** tmdo_* options and transients were left behind; a reinstall then saw stale deferrals and indexes (F-5). */
public function test_uninstall_removes_tmdo_options_and_transients(): void {
global $wpdb;
foreach ( array( 'tmdo_schema_deferred', 'tmdo_entity_indexes', '_transient_tmdo_cov_x', '_transient_timeout_tmdo_cov_x', 'hivepress_settings', 'tmdo_latepoint_db_version' ) as $name ) {
$wpdb->insert( $wpdb->options, array( 'option_name' => $name, 'option_value' => '1' ) );
$GLOBALS['_wp_options'][ $name ] = '1';
}
WPDO_Installer::drop_all_tables_for_current_blog();
foreach ( array( 'tmdo_schema_deferred', 'tmdo_entity_indexes', '_transient_tmdo_cov_x', '_transient_timeout_tmdo_cov_x' ) as $name ) {
$this->assertArrayNotHasKey( $name, $GLOBALS['_wp_options'], $name );
}
$this->assertArrayHasKey( 'hivepress_settings', $GLOBALS['_wp_options'], 'other plugins untouched' );
$this->assertArrayHasKey( 'tmdo_latepoint_db_version', $GLOBALS['_wp_options'], 'the LatePoint add-on owns it' );
}
public function test_drops_static_tables(): void { public function test_drops_static_tables(): void {
global $wpdb; global $wpdb;
+73
View File
@@ -139,4 +139,77 @@ class NativeRestorerTest extends TestCase {
$this->assertSame( array( '9' ), $this->native( 1, 'level' ) ); $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}`" );
}
}
} }
@@ -215,6 +215,38 @@ class PostBackfillJsonTest extends TestCase {
$this->assertSame( 0, $result['errors'] ?? -1 ); $this->assertSame( 0, $result['errors'] ?? -1 );
} }
// ── backfill_group() / backfillable_groups() (2.2 PR-067) ───────────────
public function test_backfill_group_fills_json_columns_instead_of_skipping_them(): void {
$this->seed_attachment( 1, array( 'width' => 640 ), 'Alt text' );
$result = WPDO_Post_Migration::backfill_group( 'attachment' );
$this->assertSame( 'attachment', $result['group'] );
global $wpdb;
$row = $wpdb->get_row( 'SELECT _wp_attachment_metadata, _wp_attachment_image_alt FROM `' . self::FLAT . '` WHERE post_id = 1', ARRAY_A );
$this->assertSame( array( 'width' => 640 ), json_decode( (string) $row['_wp_attachment_metadata'], true ), 'json field must not be left NULL by the bulk pivot' );
$this->assertSame( 'Alt text', $row['_wp_attachment_image_alt'] );
}
public function test_backfillable_groups_are_the_registered_post_groups(): void {
WPDO_Entity_Registry::register_group( 'post', 'itest_extra', array( array( 'key' => 'itest_extra_key', 'type' => 'text' ) ) );
$groups = WPDO_Post_Migration::backfillable_groups();
$this->assertSame( WPDO_Entity_Registry::get_groups_for_type( 'post' ), $groups );
$this->assertContains( 'itest_extra', $groups, 'a group registered by an add-on must be offered for backfill' );
}
public function test_restored_orphan_groups_are_never_backfilled(): void {
WPDO_Entity_Registry::restore_group( 'post', 'itest_orphan', array( array( 'key' => 'itest_orphan_key', 'type' => 'text' ) ) );
$this->assertContains( 'itest_orphan', WPDO_Entity_Registry::get_groups_for_type( 'post' ) );
$this->assertNotContains( 'itest_orphan', WPDO_Post_Migration::backfillable_groups(), 'orphan data lives only in the flat table' );
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Migration::backfill_group( 'itest_orphan' );
}
public function test_returns_error_for_unknown_group(): void { public function test_returns_error_for_unknown_group(): void {
$result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' ); $result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' );
@@ -129,6 +129,7 @@ class PostEntityLifecycleTest extends TestCase {
WPDO_Entity_Registry::init(); WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields(); WPDO_Post_Fields::register_entity_fields();
tmdo_test_register_wc_product_group();
} }
public static function tearDownAfterClass(): void { public static function tearDownAfterClass(): void {
+1
View File
@@ -93,6 +93,7 @@ class PostMigrationTest extends TestCase {
WPDO_Entity_Registry::init(); WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields(); WPDO_Post_Fields::register_entity_fields();
tmdo_test_register_wc_product_group();
} }
public static function tearDownAfterClass(): void { public static function tearDownAfterClass(): void {
+106 -6
View File
@@ -31,7 +31,7 @@ class PostQueryRouterCopyTest extends TestCase {
protected function setUp(): void { protected function setUp(): void {
global $wpdb; global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); $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( '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_id (post_status, post_date, post_id) ) 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')" ); $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_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'ready' ) );
@@ -58,14 +58,112 @@ class PostQueryRouterCopyTest extends TestCase {
$mode->setValue( null, null ); $mode->setValue( null, null );
} }
/** @return array{join:string, where:string, orderby:string, groupby:string} */ /**
* The full request WordPress would build for the routed search, after posts_request.
*
* @param string $extra_join Another JOIN (a tax query's).
* @param string $ties ID tie-breaks plugins appended.
*/
private function request( string $extra_join = '', string $ties = ', {posts}.ID DESC, {posts}.ID DESC' ): string {
global $wpdb;
$ties = str_replace( '{posts}', $wpdb->posts, $ties );
$query = new WP_Query();
foreach ( array(
'post_type' => 'rc_item',
'post_status' => 'publish',
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
$request = 'SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts ' . $router->posts_join( $extra_join, $query )
. ' WHERE 1=1 ' . $router->posts_where( " AND wp_posts.post_status = 'publish'", $query )
. " GROUP BY {$wpdb->posts}.ID ORDER BY " . $router->posts_orderby( 'wp_posts.post_date DESC', $query ) . $ties . ' LIMIT 0, 10';
return $router->posts_request( $request, $query );
}
/**
* 2.4 PR-128: with GROUP BY or a posts.ID tie-break MariaDB sorts every
* match (dev36, 235k of 1.3M listings: 1.7 s); without them it walks
* (post_status, post_date, post_id) backwards and stops at the LIMIT (3 ms).
*/
public function test_copy_route_orders_by_the_flat_date_and_post_id_without_group_by(): void {
$sql = $this->request();
$this->assertStringNotContainsString( 'GROUP BY', $sql, 'one flat row per post: nothing to collapse' );
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql );
}
public function test_copy_route_keeps_each_tie_break_direction(): void {
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` ASC LIMIT 0, 10', $this->request( '', ', {posts}.ID ASC' ) );
$this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC LIMIT 0, 10', $this->request( '', '' ) );
}
public function test_copy_route_leaves_posts_id_inside_expressions_alone(): void {
global $wpdb;
$sql = $this->request( '', ', FIELD({posts}.ID,5,9) DESC, ({posts}.ID IN (3,4)) DESC, {posts}.ID DESC' );
$this->assertStringEndsWith( "`post_date` DESC, FIELD({$wpdb->posts}.ID,5,9) DESC, ({$wpdb->posts}.ID IN (3,4)) DESC, " . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql );
}
/**
* The router's query vars are its own: values a caller passed in
* (new WP_Query( array( 'wpdo_post_copy_route' => … ) )) are dropped
* (2.4 security review L2).
*/
public function test_query_vars_passed_by_a_caller_are_dropped(): void {
$query = new WP_Query();
foreach ( array(
'post_type' => 'rc_item',
'orderby' => 'title',
'wpdo_post_copy_route' => array( 'post_type' => 'rc_item', 'group' => 'rc_core', 'statuses' => array( 'publish' ), 'order' => 'DESC, (SELECT 1)' ),
'wpdo_post_sort' => array( 'post_type' => 'rc_item', 'group' => 'rc_core', 'column' => 'rc_price', 'order' => 'ASC' ),
'wpdo_post_clauses' => array( 'rc_item' => array() ),
'wpdo_post_count_sql' => 'SELECT 1',
) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
foreach ( array( 'wpdo_post_copy_route', 'wpdo_post_sort', 'wpdo_post_clauses', 'wpdo_post_count_sql' ) as $var ) {
$this->assertEmpty( $query->get( $var ), $var );
}
$this->assertSame( 'wp_posts.post_title ASC', $router->posts_orderby( 'wp_posts.post_title ASC', $query ) );
}
/**
* wp_insert_post runs after transition_post_status: a new post's empty flat
* row must carry the copies itself, or one NULL turns the copy route off for
* the whole group (2.4 correctness review).
*/
public function test_a_new_post_gets_its_flat_row_with_the_copies(): void {
global $wpdb;
$post = new WP_Post( (object) array( 'ID' => 7, 'post_type' => 'rc_item', 'post_status' => 'draft', 'post_date' => '2026-02-02 00:00:00' ) );
$GLOBALS['_wp_posts'][7] = $post;
( new TMDO_Post_Query_Router() )->add_flat_rows( 7, $post, false );
$this->assertSame( array( 'post_status' => 'draft', 'post_date' => '2026-02-02 00:00:00' ), $wpdb->get_row( 'SELECT post_status, post_date FROM `' . self::TABLE . '` WHERE post_id = 7', ARRAY_A ) );
unset( $GLOBALS['_wp_posts'][7] );
}
public function test_copy_route_keeps_group_by_with_another_join(): void {
global $wpdb;
$sql = $this->request( " LEFT JOIN wp_term_relationships ON ({$wpdb->posts}.ID = wp_term_relationships.object_id)" );
$this->assertStringContainsString( "GROUP BY {$wpdb->posts}.ID", $sql );
}
/** @return array{join:string, where:string, orderby:string} */
private function sql( array $vars ): array { private function sql( array $vars ): array {
$query = new WP_Query(); $query = new WP_Query();
foreach ( array_merge( foreach ( array_merge(
array( array(
'post_type' => 'rc_item', 'post_type' => 'rc_item',
'post_status' => 'publish', 'post_status' => 'publish',
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN' ) ), 'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
), ),
$vars $vars
) as $k => $v ) { ) as $k => $v ) {
@@ -77,7 +175,6 @@ class PostQueryRouterCopyTest extends TestCase {
'join' => $router->posts_join( '', $query ), 'join' => $router->posts_join( '', $query ),
'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ), 'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ),
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ), 'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
'groupby' => $router->posts_groupby( '', $query ),
); );
} }
@@ -85,7 +182,6 @@ class PostQueryRouterCopyTest extends TestCase {
$this->assertStringContainsString( 'LEFT JOIN', $sql['join'] ); $this->assertStringContainsString( 'LEFT JOIN', $sql['join'] );
$this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] ); $this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] ); $this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
$this->assertSame( '`wp_itest_posts`.`ID`', $sql['groupby'] );
} }
public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void { public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void {
@@ -95,7 +191,6 @@ class PostQueryRouterCopyTest extends TestCase {
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] ); $this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] );
$this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' ); $this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' );
$this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] ); $this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] );
$this->assertSame( '', $sql['groupby'], 'one row per post: no GROUP BY, so LIMIT can stop early' );
} }
public function test_several_explicit_statuses_are_kept(): void { public function test_several_explicit_statuses_are_kept(): void {
@@ -123,4 +218,9 @@ class PostQueryRouterCopyTest extends TestCase {
$this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) ); $this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) );
$this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) ); $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,133 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* The post query router leaves a query native while the last drift-check
* found a routed clause's group out of step with native meta (2.2 PR-072).
* All or nothing: one drifting group keeps the whole query native. A group
* never checked keeps routing (decision 2026-09-27).
*/
class PostQueryRouterDriftTest extends TestCase {
public static function setUpBeforeClass(): void {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
tmdo_test_register_wc_product_group();
}
public static function tearDownAfterClass(): void {
self::set_modes( null );
WPDO_Entity_Registry::init();
}
protected function setUp(): void {
$GLOBALS['_wp_options'] = array();
self::set_modes(
array(
'post' => 'shadow_read',
'user' => 'disabled',
'term' => 'disabled',
'comment' => 'disabled',
)
);
}
private static function set_modes( ?array $modes ): void {
$cache = ( new ReflectionClass( WPDO_Mode_Manager::class ) )->getProperty( 'cache' );
$cache->setAccessible( true );
$cache->setValue( null, $modes );
}
private function drift( string $group, int $diff ): void {
$GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ]['post'][ $group ] = array(
'miss' => 0,
'diff' => $diff,
'extra' => 0,
'sample' => 0,
'checked_at' => '2026-09-27 00:00:00',
);
}
private function price_query( array $extra = array() ): WP_Query {
$query = new WP_Query();
foreach ( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
),
) + $extra as $k => $v ) {
$query->set( $k, $v );
}
return $query;
}
public function test_a_drifting_group_keeps_the_query_native(): void {
$this->drift( 'wc_product', 3 );
$query = $this->price_query();
$original = $query->get( 'meta_query' );
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertSame( $original, $query->get( 'meta_query' ) );
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) );
}
public function test_a_clean_group_is_routed(): void {
$this->drift( 'wc_product', 0 );
$query = $this->price_query();
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
}
public function test_a_group_never_checked_is_routed(): void {
$query = $this->price_query();
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
}
public function test_drift_in_another_group_does_not_matter(): void {
$this->drift( 'hp_listing_core', 5 );
$query = $this->price_query();
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
}
public function test_aeav_only_ignores_drift_because_native_is_stale_by_design(): void {
self::set_modes(
array(
'post' => 'aeav_only',
'user' => 'disabled',
'term' => 'disabled',
'comment' => 'disabled',
)
);
$this->drift( 'wc_product', 3 );
$query = $this->price_query();
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertNotEmpty( $query->get( 'wpdo_post_clauses' ) );
}
public function test_sorting_by_a_drifting_key_stays_native(): void {
$this->drift( 'wc_product', 1 );
$query = new WP_Query();
$query->set( 'post_type', 'product' );
$query->set( 'meta_key', '_price' );
$query->set( 'orderby', 'meta_value_num' );
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertSame( '_price', $query->get( 'meta_key' ) );
$this->assertSame( '', $query->get( 'wpdo_post_sort' ) );
}
}
@@ -75,12 +75,12 @@ class PostQueryRouterFoundRowsTest extends TestCase {
} }
/** A routed query plus the SQL WordPress would build for it (page size 1). */ /** A routed query plus the SQL WordPress would build for it (page size 1). */
private function routed(): array { private function routed( array $clause = array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=', 'type' => 'NUMERIC' ) ): array {
global $wpdb; global $wpdb;
$query = new WP_Query(); $query = new WP_Query();
$query->set( 'post_type', 'fr_item' ); $query->set( 'post_type', 'fr_item' );
$query->set( 'post_status', 'publish' ); $query->set( 'post_status', 'publish' );
$query->set( 'meta_query', array( array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=' ) ) ); $query->set( 'meta_query', array( $clause ) );
$router = new TMDO_Post_Query_Router(); $router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query ); $router->pre_get_posts( $query );
@@ -90,6 +90,17 @@ class PostQueryRouterFoundRowsTest extends TestCase {
return array( $router, $query, $request ); return array( $router, $query, $request );
} }
public function test_a_group_waiting_for_its_table_upgrade_is_not_routed(): void {
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, array( 'post' => 'shadow_read', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'post/fr_core' => time() ) );
list( , $query ) = $this->routed();
$this->assertEmpty( $query->get( 'wpdo_post_clauses' ), 'kept native' );
}
public function test_routed_request_drops_sql_calc_found_rows(): void { public function test_routed_request_drops_sql_calc_found_rows(): void {
list( $router, $query, $request ) = $this->routed(); list( $router, $query, $request ) = $this->routed();
@@ -117,13 +128,85 @@ class PostQueryRouterFoundRowsTest extends TestCase {
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 2' ); $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' ); $this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'cached' );
wp_cache_set_posts_last_changed(); wp_cache_set_posts_last_changed(); // Any post meta write, e.g. a theme's CSS descriptor on a page view.
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'still cached' );
TMDO_Post_Query_Router::bump_count_version(); // A post saved, or a managed key written.
$this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' ); $this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' );
} }
/**
* HivePress excludes a different random set of posts on each search page;
* the total is the cached count without the exclusion minus a live count
* of the excluded posts that match (by primary key).
*/
public function test_excluded_posts_do_not_split_the_cached_total(): void {
global $wpdb;
$count = function ( string $not_in ) use ( $wpdb ): string {
list( $router, $query, $request ) = $this->routed();
$request = str_replace( ' GROUP BY', " AND {$wpdb->posts}.ID NOT IN ({$not_in}) GROUP BY", $request );
$router->posts_request( $request, $query );
return (string) $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) );
};
$this->assertSame( '1', $count( '3' ), 'prices 20, 30 match; 3 excluded' );
$this->assertSame( '0', $count( '2,3' ) );
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 3' ); // Not seen until posts change.
$this->assertSame( '2', $count( '1' ), 'one cached count serves every exclusion list; 1 does not match' );
$this->assertSame( '1', $count( '2' ) );
TMDO_Post_Query_Router::bump_count_version();
$this->assertSame( '0', $count( '2' ), 'recounted' );
}
/**
* Keyword searches carry wpdb's placeholder escape for "%", which changes
* with every request: the cache key uses the SQL without it, or no search
* with a keyword ever hits the cached count.
*/
public function test_the_cached_count_survives_a_new_placeholder_escape(): void {
global $wpdb;
$real = $wpdb;
$wpdb = new class( $real ) {
public int $counts = 0;
public function __construct( private object $db ) {}
public function remove_placeholder_escape( string $sql ): string {
return (string) preg_replace( '/\{[0-9a-f]{8}\}/', '%', $sql );
}
public function __call( string $name, array $args ) {
if ( 'get_var' === $name && str_contains( (string) $args[0], 'COUNT(*)' ) ) {
++$this->counts;
}
return $this->db->$name( ...$args );
}
public function __get( string $name ) {
return $this->db->$name;
}
};
try {
foreach ( array( '{1a2b3c4d}', '{9f8e7d6c}' ) as $escape ) { // Two requests.
list( $router, $query, $request ) = $this->routed();
$request = str_replace( ' GROUP BY', " AND '{$escape}' = '{$escape}' GROUP BY", $request );
$router->posts_request( $request, $query );
$this->assertSame( '2', $real->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) );
}
$counts = $wpdb->counts;
} finally {
$wpdb = $real;
}
$this->assertSame( 1, $counts, 'counted once' );
}
public function test_flat_writes_and_deletes_move_posts_last_changed(): void { public function test_flat_writes_and_deletes_move_posts_last_changed(): void {
$GLOBALS['_wp_current_filter'] = 'update_post_metadata'; $GLOBALS['_wp_current_filter'] = 'update_post_metadata';
$before = wp_cache_get_last_changed( 'posts' ); $before = wp_cache_get_last_changed( 'posts' );
$version = wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP );
WPDO_Hook_Bus::intercept_update( null, 1, 'fr_price', '11', '' ); WPDO_Hook_Bus::intercept_update( null, 1, 'fr_price', '11', '' );
$after_write = wp_cache_get_last_changed( 'posts' ); $after_write = wp_cache_get_last_changed( 'posts' );
@@ -132,5 +215,165 @@ class PostQueryRouterFoundRowsTest extends TestCase {
$this->assertNotSame( $before, $after_write ); $this->assertNotSame( $before, $after_write );
$this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) ); $this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) );
$this->assertNotSame( $version, wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP ), 'routed counts follow flat writes' );
}
// ── 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 = '>=', string $order = 'DESC' ): 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', $order );
$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 LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id, once' );
$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 ) );
}
/**
* The ID tie-break follows the sort direction, so a (column, post_id)
* index returns rows in order with no filesort (2.4 PR-118); ties are
* ordered by ID the same way as the column.
*/
public function test_flat_first_tie_break_follows_the_sort_direction(): void {
global $wpdb;
list( $router, $query, $request ) = $this->sorted( '>=', 'ASC' );
$sql = $router->posts_request( $request, $query );
$this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` ASC, `wpdoflat_fr_item_fr_core`.`post_id` ASC LIMIT 0, 10', $sql );
$this->assertSame( array( '2', '3' ), $wpdb->get_col( $sql ) );
}
/**
* Only standalone posts.ID terms are tie-breaks: inside an expression a
* plugin added (pinning posts with FIELD() or IN) the direction must not be
* inserted, or the SQL breaks (2.4 correctness review).
*/
public function test_flat_first_leaves_posts_id_inside_expressions_alone(): void {
global $wpdb;
list( $router, $query, $request ) = $this->sorted();
$pinned = str_replace( ' LIMIT 0, 10', ", FIELD({$wpdb->posts}.ID,3,2) DESC, ({$wpdb->posts}.ID IN (2)) DESC LIMIT 0, 10", $request );
$sql = $router->posts_request( $pinned, $query );
$this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql );
$this->assertStringContainsString( "FIELD({$wpdb->posts}.ID,3,2) DESC, ({$wpdb->posts}.ID IN (2)) DESC LIMIT", $sql );
$wpdb->last_error = '';
$this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $pinned ) ), $wpdb->get_col( $sql ) );
$this->assertSame( '', $wpdb->last_error );
}
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,139 @@
<?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();
tmdo_test_register_wc_product_group();
}
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'] );
}
}
+299 -7
View File
@@ -39,6 +39,7 @@ class PostQueryRouterTest extends TestCase {
WPDO_Entity_Registry::init(); WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields(); WPDO_Post_Fields::register_entity_fields();
tmdo_test_register_wc_product_group();
} }
public static function tearDownAfterClass(): void { public static function tearDownAfterClass(): void {
@@ -78,7 +79,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array( $query = $this->make_query( array(
'post_type' => 'product', 'post_type' => 'product',
'meta_query' => array( 'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
), ),
) ); ) );
$original = $query->get( 'meta_query' ); $original = $query->get( 'meta_query' );
@@ -100,7 +101,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array( $query = $this->make_query( array(
'post_type' => 'product', 'post_type' => 'product',
'meta_query' => array( 'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
), ),
) ); ) );
$original = $query->get( 'meta_query' ); $original = $query->get( 'meta_query' );
@@ -123,7 +124,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array( $query = $this->make_query( array(
'post_type' => 'product', 'post_type' => 'product',
'meta_query' => array( 'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
), ),
) ); ) );
@@ -149,7 +150,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array( $query = $this->make_query( array(
'post_type' => 'product', 'post_type' => 'product',
'meta_query' => array( 'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
array( 'key' => 'unmanaged_attr', 'value' => 'x' ), array( 'key' => 'unmanaged_attr', 'value' => 'x' ),
), ),
) ); ) );
@@ -177,7 +178,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array( $query = $this->make_query( array(
'post_type' => 'product', 'post_type' => 'product',
'meta_query' => array( 'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=' ), array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
), ),
) ); ) );
$original = $query->get( 'meta_query' ); $original = $query->get( 'meta_query' );
@@ -202,7 +203,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array( $query = $this->make_query( array(
'post_type' => 'product', 'post_type' => 'product',
'meta_query' => array( 'meta_query' => array(
array( 'key' => '_price', 'value' => '50' ), array( 'key' => '_price', 'value' => '50', 'type' => 'NUMERIC' ),
), ),
'wpdo_post_clauses' => array(), 'wpdo_post_clauses' => array(),
) ); ) );
@@ -214,6 +215,34 @@ class PostQueryRouterTest extends TestCase {
$this->assertStringContainsString( 'wpdo_post_wc_product', $join ); $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 { public function test_posts_where_appends_condition_for_routed_clause(): void {
$this->set_post_mode( 'aeav_only' ); $this->set_post_mode( 'aeav_only' );
@@ -221,7 +250,7 @@ class PostQueryRouterTest extends TestCase {
$query = $this->make_query( array( $query = $this->make_query( array(
'post_type' => 'product', 'post_type' => 'product',
'meta_query' => array( 'meta_query' => array(
array( 'key' => '_price', 'value' => '99', 'compare' => '=' ), array( 'key' => '_price', 'value' => '99', 'compare' => '=', 'type' => 'NUMERIC' ),
), ),
) ); ) );
@@ -242,4 +271,267 @@ class PostQueryRouterTest extends TestCase {
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ) ); $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 ) );
}
/**
* A clause with no value tests only that the key exists (WP_Meta_Query adds
* a value condition only when 'value' is set), so it routes as EXISTS.
* HivePress's featured-listing lookup on every search page is one:
* array( 'key' => 'hp_featured' ) — it kept the whole query native (2.4 PR-122).
*/
public function test_a_clause_without_a_value_routes_as_exists(): 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' => '_stock' ),
array( 'key' => '_price', 'value' => array( 10, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
),
)
);
$router->pre_get_posts( $query );
$this->assertEmpty( $query->get( 'meta_query' ), 'both clauses routed' );
$this->assertStringContainsString( '`_stock` IS NOT NULL', $router->posts_where( '', $query ) );
}
/**
* HivePress's featured-listing lookup (Attribute::set_search_query()): the
* search's meta query, including its sort group, plus meta_key hp_featured,
* ordered at random. The sort group (key NOT EXISTS OR key EXISTS) matches
* every post, so unused for ordering it is dropped; meta_key without a
* value or a meta_value sort tests that the key exists (2.4 PR-122).
*/
public function test_hivepress_featured_lookup_is_routed(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query(
array(
'post_type' => 'product',
'meta_key' => '_stock',
'orderby' => 'rand',
'meta_query' => array(
array(
'relation' => 'OR',
'price__order' => array( 'key' => '_price', 'compare' => 'NOT EXISTS', 'type' => 'DECIMAL' ),
array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'DECIMAL' ),
),
array( 'key' => '_price', 'value' => array( 10, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
),
)
);
$router->pre_get_posts( $query );
$this->assertSame( '', $query->get( 'meta_key' ), 'meta_key routed' );
$this->assertEmpty( array_filter( (array) $query->get( 'meta_query' ), 'is_array' ), 'sort group dropped, filter routed' );
$where = $router->posts_where( '', $query );
$this->assertStringContainsString( '`_stock` IS NOT NULL', $where );
$this->assertStringContainsString( 'BETWEEN', $where );
}
/**
* WordPress orders by the primary meta clause (meta_key) for meta_value,
* meta_value_num or the key's name anywhere in orderby; routing the key and
* clearing meta_key would drop that ordering (2.4 correctness review).
*/
public function test_meta_key_ordered_by_among_several_orderings_stays_native(): void {
foreach ( array(
array( 'meta_value_num' => 'ASC', 'title' => 'ASC' ),
'meta_value date',
'_stock',
) as $orderby ) {
$this->set_post_mode( 'aeav_only' );
$query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'orderby' => $orderby ) );
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$label = is_array( $orderby ) ? implode( ',', array_keys( $orderby ) ) : $orderby;
$this->assertSame( '_stock', $query->get( 'meta_key' ), $label );
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $label );
}
}
/** meta_compare_key LIKE matches other keys too: not a test on one flat column. */
public function test_meta_key_with_a_key_comparison_stays_native(): void {
foreach ( array( 'rand', 'meta_value_num' ) as $orderby ) {
$this->set_post_mode( 'aeav_only' );
$query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'meta_compare_key' => 'LIKE', 'orderby' => $orderby ) );
( new WPDO_Post_Query_Router() )->pre_get_posts( $query );
$this->assertSame( '_stock', $query->get( 'meta_key' ), $orderby );
$this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $orderby );
}
}
/**
* Without meta_key the first clause is WordPress's primary one: orderby
* meta_value_num sorts by the sort group's key, so the group stays.
*/
public function test_sort_group_ordered_as_the_primary_clause_is_not_dropped(): void {
$this->assert_not_routed(
array(
'post_type' => 'product',
'orderby' => 'meta_value_num',
'meta_query' => array(
array(
'relation' => 'OR',
'price__order' => array( 'key' => '_price', 'compare' => 'NOT EXISTS', 'type' => 'DECIMAL' ),
array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'DECIMAL' ),
),
array( 'key' => '_stock', 'value' => 1, 'compare' => '>=', 'type' => 'NUMERIC' ),
),
),
'primary sort group'
);
}
public function test_meta_key_with_a_value_is_left_alone(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'meta_value' => '5' ) );
$router->pre_get_posts( $query );
$this->assertSame( '_stock', $query->get( 'meta_key' ) );
}
} }
+117 -3
View File
@@ -25,6 +25,9 @@ class PostStatusSyncTest extends TestCase {
'includes/adapters/class-tmdo-adapter-post.php', 'includes/adapters/class-tmdo-adapter-post.php',
'includes/engine/class-tmdo-hook-bus.php', 'includes/engine/class-tmdo-hook-bus.php',
'includes/engine/class-tmdo-post-status-sync.php', 'includes/engine/class-tmdo-post-status-sync.php',
'includes/engine/class-tmdo-entity-migration-engine.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/query/class-tmdo-post-query-router.php',
) as $file ) { ) as $file ) {
require_once $base . $file; require_once $base . $file;
} }
@@ -81,9 +84,34 @@ class PostStatusSyncTest extends TestCase {
return $out; return $out;
} }
private function has_index( string $table ): bool { /** @return array<string, string[]> index name → columns */
private function indexes( string $table ): array {
global $wpdb; global $wpdb;
return in_array( 'idx_post_status_date', array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ), true ); $out = array();
foreach ( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ) as $row ) {
$out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name'];
}
return $out;
}
private function has_index( string $table ): bool {
return array( 'post_status', 'post_date', 'post_id' ) === ( $this->indexes( $table )['idx_post_status_date_id'] ?? null );
}
/**
* 2.4 PR-128: the date route's ID tie-break is read from post_id, which the
* index must end with; the 2.2 two-column index is replaced, not kept beside it.
*/
public function test_run_replaces_the_two_column_index_of_existing_tables(): void {
global $wpdb;
$wpdb->query( 'ALTER TABLE `' . self::A . '` ADD COLUMN post_status VARCHAR(20) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL, ADD COLUMN post_date DATETIME DEFAULT NULL, ADD INDEX idx_post_status_date (post_status, post_date)' );
$GLOBALS['_wp_options'][ TMDO_Post_Status_Sync::OPTION ] = array( 'ps_a' => 'ready' );
TMDO_Post_Status_Sync::run();
$this->assertTrue( $this->has_index( self::A ) );
$this->assertArrayNotHasKey( 'idx_post_status_date', $this->indexes( self::A ) );
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
} }
public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void { public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void {
@@ -104,6 +132,16 @@ class PostStatusSyncTest extends TestCase {
$this->assertSame( 2, $report['ps_a']['mismatched'] ); $this->assertSame( 2, $report['ps_a']['mismatched'] );
} }
/** Routed counts cached while copies were stale are dropped by a sync (2.4 correctness review). */
public function test_a_sync_invalidates_cached_routed_counts_but_a_dry_run_does_not(): void {
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
TMDO_Post_Status_Sync::run( true );
$this->assertSame( 'before', $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] );
TMDO_Post_Status_Sync::run();
$this->assertNotSame( 'before', $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] );
}
public function test_copies_use_the_posts_collation_so_they_compare_with_posts(): void { public function test_copies_use_the_posts_collation_so_they_compare_with_posts(): void {
global $wpdb; global $wpdb;
TMDO_Post_Status_Sync::run(); TMDO_Post_Status_Sync::run();
@@ -134,6 +172,23 @@ class PostStatusSyncTest extends TestCase {
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2], 'other posts untouched' ); $this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2], 'other posts untouched' );
} }
public function test_a_group_whose_table_was_dropped_is_forgotten(): void {
global $wpdb;
TMDO_Post_Status_Sync::run();
$wpdb->query( 'DROP TABLE `' . self::B . '`' );
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
$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, 'no UPDATE of a missing table' );
$this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_b' ), 'forgotten' );
$this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) );
$this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::A )[1] );
}
public function test_transition_leaves_tables_without_the_columns_alone(): void { public function test_transition_leaves_tables_without_the_columns_alone(): void {
global $wpdb; global $wpdb;
$wpdb->last_error = ''; $wpdb->last_error = '';
@@ -156,11 +211,70 @@ class PostStatusSyncTest extends TestCase {
$this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) ); $this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) );
} }
/**
* The migration engine (backfill, drift-check --fix) REPLACEs whole rows:
* without the copies, a rebuilt row had NULL post_status, which turns the
* status/date copy route off for the whole group (2.4 PR-127).
*/
public function test_a_row_rebuilt_from_native_keeps_its_copies(): void {
global $wpdb;
TMDO_Post_Status_Sync::run();
$GLOBALS['_wp_posts'][2] = (object) array( 'ID' => 2, 'post_type' => 'hp_listing', 'post_status' => 'draft', 'post_date' => '2026-02-01 10:00:00' );
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' );
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" );
try {
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native'), (3, 'k', 'a3-native')" );
WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ); // Existing row.
WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 3 ); // New row.
} finally {
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
}
$this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2] );
$this->assertSame( array( 'pending', '2026-03-01 10:00:00' ), $this->copies( self::A )[3] );
}
/**
* Backfills and drift-check --fix rebuild rows outside the Hook Bus: the
* routed counts cached for the old rows must go (2.4 PR-129; dev36: a
* vendor search kept counting 21515 of 21522 matches after a fix).
*/
public function test_rebuilding_post_rows_invalidates_cached_routed_counts(): void {
global $wpdb;
$status = $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status';
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" );
$wpdb->query( "CREATE TABLE IF NOT EXISTS `{$status}` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
$version = static fn() => $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] ?? null;
try {
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native')" );
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0, 'dry_run' => true ) );
$this->assertSame( 'before', $version(), 'a dry run writes nothing' );
foreach ( array(
'migrate_entity' => static fn() => TMDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ),
'migrate_group' => static fn() => TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0 ) ),
'migrate_group_batch' => static fn() => TMDO_Entity_Migration_Engine::migrate_group_batch( 'post', 'ps_a' ),
) as $path => $run ) {
TMDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'ps_a' );
$GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before';
$run();
$this->assertNotSame( 'before', $version(), $path );
}
} finally {
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$status}`" );
}
}
public function test_flat_write_that_creates_a_row_fills_the_copies(): void { public function test_flat_write_that_creates_a_row_fills_the_copies(): void {
TMDO_Post_Status_Sync::run(); TMDO_Post_Status_Sync::run();
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true ); $mode->setAccessible( true );
$mode->setValue( null, array( 'post' => 'dual_write', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); // aeav_only: the pre-filter itself writes flat (dual_write syncs from native after the write).
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
$GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' ); $GLOBALS['_wp_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'; $GLOBALS['_wp_current_filter'] = 'update_post_metadata';
+41 -1
View File
@@ -33,6 +33,7 @@ class RestApiIntegrationTest extends TestCase {
'zone' => 'hot', 'zone' => 'hot',
'column' => 'rp_price', 'column' => 'rp_price',
'type' => 'decimal', 'type' => 'decimal',
'show_in_rest' => true,
] ); ] );
$registry->register( 'integration_rest', [ $registry->register( 'integration_rest', [
'post_type' => 'restapi', 'post_type' => 'restapi',
@@ -40,11 +41,21 @@ class RestApiIntegrationTest extends TestCase {
'zone' => 'hot', 'zone' => 'hot',
'column' => 'rp_featured', 'column' => 'rp_featured',
'type' => 'tinyint', 'type' => 'tinyint',
'show_in_rest' => true,
] ); ] );
$registry->register( 'integration_rest', [ $registry->register( 'integration_rest', [
'post_type' => 'restapi', 'post_type' => 'restapi',
'meta_key' => 'rp_description', 'meta_key' => 'rp_description',
'zone' => 'cold', '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. // Create hot table.
@@ -53,6 +64,7 @@ class RestApiIntegrationTest extends TestCase {
post_id BIGINT UNSIGNED NOT NULL, post_id BIGINT UNSIGNED NOT NULL,
rp_price DECIMAL(10,2) DEFAULT NULL, rp_price DECIMAL(10,2) DEFAULT NULL,
rp_featured TINYINT(1) DEFAULT NULL, rp_featured TINYINT(1) DEFAULT NULL,
rp_cost DECIMAL(10,2) DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (post_id) PRIMARY KEY (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
@@ -91,7 +103,8 @@ class RestApiIntegrationTest extends TestCase {
$featured = $i % 2; $featured = $i % 2;
$status = $statuses[ $i ] ?? 'publish'; $status = $statuses[ $i ] ?? 'publish';
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES ($i, 'restapi', '$status')" ); $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 `{$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)" ); $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 ); $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 { public function test_listings_filter_price_min(): void {
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
$req->set_param( 'post_type', 'restapi' ); $req->set_param( 'post_type', 'restapi' );
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* An existing flat table whose definition changed is upgraded from WP-CLI /
* WP-Cron only (2.3 PR-107); web requests record the group as deferred.
*/
class SchemaUpgradeDeferralTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_sd_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-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/adapters/class-tmdo-adapter-user.php',
) as $file ) {
require_once $base . $file;
}
WPDO_Installer::install_v2_tables();
}
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, user_id BIGINT NOT NULL, sd_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
$wpdb->insert( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core', 'schema_hash' => 'old', 'field_definitions' => '[]' ) );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_scheduled_single'] = array();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); // A web request.
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
self::set_user_mode( 'dual_write' );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', 'sd_core', array( array( 'key' => 'sd_a', 'type' => 'text' ), array( 'key' => 'sd_b', 'type' => 'integer' ) ) );
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
WPDO_Entity_Registry::init();
self::set_user_mode( null );
unset( $GLOBALS['_wp_scheduled_single'] );
}
private static function set_user_mode( ?string $mode ): void {
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$ref->setAccessible( true );
$ref->setValue( null, null === $mode ? null : array( 'post' => 'disabled', 'user' => $mode, 'term' => 'disabled', 'comment' => 'disabled' ) );
}
private function upgrade(): bool {
return TMDO_Schema_Manager::create_or_upgrade_table( 'user', 'sd_core', WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
}
public function test_a_web_request_leaves_the_upgrade_to_wp_cron_and_the_group_native(): void {
global $wpdb;
$this->assertFalse( $this->upgrade() );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
$this->assertSame( array( 'id', 'user_id', 'sd_a' ), $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::TABLE . '`' ), 'no ALTER' );
$this->assertSame( 'old', TMDO_Schema_Manager::get_stored_schema_hash( 'user', 'sd_core' ) );
}
public function test_only_existing_tables_outside_aeav_only_and_outside_cli_or_cron_wait(): void {
$this->assertTrue( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', 'wp_itest_wpdo_user_sd_missing' ), 'a new table is created at once' );
self::set_user_mode( 'aeav_only' );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ), 'aeav_only: the flat table is the only copy' );
self::set_user_mode( 'dual_write' );
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // WP-CLI / WP-Cron.
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
}
/**
* An existing table is altered by one process at a time: anonymous
* /wp-cron.php requests pass ddl_allowed() too (security review H1).
*/
public function test_an_upgrade_is_skipped_while_another_process_holds_the_schema_lock(): void {
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true );
$other = new mysqli(
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
(string) getenv( 'TMDO_TEST_DB_PASS' ),
getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test'
);
$name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() );
$other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" );
try {
$this->assertFalse( $this->upgrade(), 'returns before dbDelta (unavailable here)' );
} finally {
$other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" );
$other->close();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
}
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
}
/**
* After the upgrade the group stays native until the drift fix has
* rebuilt the rows written meanwhile (security review M1).
*/
public function test_a_drift_fix_releases_only_upgraded_groups_of_its_entity(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
update_option(
TMDO_Schema_Manager::DEFERRED_OPTION,
array(
'user/sd_core' => 1,
'user/sd_later' => 1, // Not upgraded yet (no stored definition).
'post/sd_core' => 1, // Another entity.
)
);
TMDO_Schema_Manager::resynced( 'user' );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'stored hash still old: not upgraded' );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
TMDO_Schema_Manager::resynced( 'user' );
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_later' ) );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'post', 'sd_core' ) );
}
/**
* The definition went back to the stored one (or another process upgraded
* the table) while the group was deferred (correctness review F2).
*/
public function test_a_deferred_group_whose_definition_matches_again_is_resynced_then_released(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
$this->assertTrue( $this->upgrade() );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'dual_write: native until the drift fix' );
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
self::set_user_mode( 'disabled' );
$this->upgrade();
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'no flat writes: nothing to rebuild' );
}
/**
* The cron event upgrades deferred groups itself: with ALTERNATE_WP_CRON
* it runs inside a visitor's request, after init (correctness review F3).
*/
public function test_the_cron_event_handles_deferred_groups(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
TMDO_Schema_Manager::ensure_pending_indexes();
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* TMDO_Shadow_Diff_Logger throttling (2.2 PR-075).
*
* The per-object window (one row per entity + key per 5 minutes) did not stop
* a key that is stale on every object — WooCommerce's total_sales, written by
* direct SQL — from logging a row for each product read. A key now records at
* most KEY_LIMIT rows per KEY_WINDOW across all objects.
*/
class ShadowDiffThrottleTest extends TestCase {
private const DIFFS = 'wp_itest_wpdo_shadow_diffs';
private const ERRORS = 'wp_itest_wpdo_errors';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/engine/class-tmdo-shadow-diff-logger.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::DIFFS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ts DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', entity_type VARCHAR(20) NOT NULL DEFAULT '', entity_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) NOT NULL DEFAULT '', postmeta_value LONGTEXT, zone_value LONGTEXT, diff_hash VARCHAR(40) NOT NULL DEFAULT '', PRIMARY KEY (id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::ERRORS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, module VARCHAR(100) NOT NULL DEFAULT '', zone VARCHAR(20) NOT NULL DEFAULT '', hook VARCHAR(255) NOT NULL DEFAULT '', message TEXT NOT NULL, context LONGTEXT, created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id) ) ENGINE=InnoDB" );
for ( $id = 1; $id <= 30; $id++ ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'total_sales', 'meta_value' => '5' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'other_key', 'meta_value' => '5' ) );
}
$GLOBALS['_wp_transients'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->usermeta, self::DIFFS, self::ERRORS ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function logged( string $key ): int {
global $wpdb;
return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM `' . self::DIFFS . '` WHERE meta_key = %s', $key ) );
}
private function stale_read( int $user_id, string $key ): void {
TMDO_Shadow_Diff_Logger::compare_and_log( 'user', $user_id, $key, 4, array( 'type' => 'integer' ) );
}
public function test_a_key_stale_on_every_object_logs_at_most_the_key_limit(): void {
for ( $id = 1; $id <= 30; $id++ ) {
$this->stale_read( $id, 'total_sales' );
}
$this->assertSame( TMDO_Shadow_Diff_Logger::KEY_LIMIT, $this->logged( 'total_sales' ) );
}
public function test_the_limit_is_per_key(): void {
for ( $id = 1; $id <= 30; $id++ ) {
$this->stale_read( $id, 'total_sales' );
}
$this->stale_read( 1, 'other_key' );
$this->assertSame( 1, $this->logged( 'other_key' ) );
}
public function test_the_same_object_and_key_still_log_once_per_window(): void {
$this->stale_read( 1, 'total_sales' );
$this->stale_read( 1, 'total_sales' );
$this->assertSame( 1, $this->logged( 'total_sales' ) );
}
}
@@ -29,7 +29,8 @@ class ZoneArchiveIntegrationTest extends TestCase {
`original_meta_id` bigint(20) unsigned NOT NULL DEFAULT 0, `original_meta_id` bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `post_id` (`post_id`), KEY `post_id` (`post_id`),
KEY `archived_at` (`archived_at`) KEY `archived_at` (`archived_at`),
KEY `idx_original_meta` (`original_meta_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
); );
} }
@@ -45,6 +46,69 @@ class ZoneArchiveIntegrationTest extends TestCase {
$GLOBALS['_wp_postmeta'] = []; $GLOBALS['_wp_postmeta'] = [];
} }
// ── sweep / duplicates (2.4.2 PR-136, manual F-6) ─────────────────────
/** Posts and postmeta for sweep(): post 10 trashed 200 days ago, post 11 published. */
private function seed_trashed_post(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
$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_modified_gmt DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
$wpdb->query( "CREATE TABLE `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" );
$old = gmdate( 'Y-m-d H:i:s', time() - 200 * DAY_IN_SECONDS );
$wpdb->query( "INSERT INTO `{$wpdb->posts}` VALUES (10, 'hp_listing', 'trash', '{$old}'), (11, 'hp_listing', 'publish', '{$old}')" );
$wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (10, 'hp_price', '5'), (10, 'hp_rating', '4'), (11, 'hp_price', '9')" );
}
private function drop_posts(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" );
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" );
}
private function archive_rows(): int {
global $wpdb;
return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' );
}
public function test_sweep_copies_each_trashed_meta_row_once(): void {
$this->seed_trashed_post();
try {
$this->assertSame( 2, WPDO_Zone_Archive::sweep( 90, false ) );
$this->assertSame( 0, WPDO_Zone_Archive::sweep( 90, false ), 'already archived' );
$this->assertSame( 2, $this->archive_rows() );
} finally {
$this->drop_posts();
}
}
/** Until `wp tmdo install` adds the index, NOT EXISTS would scan the archive per candidate. */
public function test_sweep_waits_for_the_original_meta_index(): void {
global $wpdb;
$this->seed_trashed_post();
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` DROP INDEX `idx_original_meta`' );
try {
$this->assertSame( 0, WPDO_Zone_Archive::sweep( 90, false ) );
$this->assertSame( 0, $this->archive_rows() );
} finally {
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` ADD KEY `idx_original_meta` (`original_meta_id`)' );
$this->drop_posts();
}
}
public function test_remove_duplicates_keeps_the_first_copy_of_each_meta_row(): void {
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 );
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 );
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 );
WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 );
WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' ); // original_meta_id 0: no identity, kept.
WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' );
$this->assertSame( 2, WPDO_Zone_Archive::remove_duplicates() );
$this->assertSame( 4, $this->archive_rows() );
$this->assertSame( 0, WPDO_Zone_Archive::remove_duplicates() );
}
// ── archive / get ───────────────────────────────────────────────────── // ── archive / get ─────────────────────────────────────────────────────
public function test_archive_stores_plain_entry(): void { public function test_archive_stores_plain_entry(): void {
+91 -7
View File
@@ -168,7 +168,12 @@ $wpdb = new class( $_mysqli ) {
fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'", fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'",
array_values( $data ) array_values( $data )
) ); ) );
$ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" ); try {
$ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" );
} catch ( mysqli_sql_exception $e ) { // Like wpdb::insert(): an SQL error returns false.
$this->last_error = $e->getMessage();
return false;
}
if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; } if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; }
$this->last_error = (string) $this->db->error; $this->last_error = (string) $this->db->error;
return false; return false;
@@ -200,7 +205,12 @@ $wpdb = new class( $_mysqli ) {
public function flush(): void {} public function flush(): void {}
public function query( string $sql ): int|bool { public function query( string $sql ): int|bool {
$result = $this->db->query( $sql ); try {
$result = $this->db->query( $sql );
} catch ( mysqli_sql_exception $e ) { // Like wpdb::query(): an SQL error returns false.
$this->last_error = $e->getMessage();
return false;
}
if ( $result instanceof mysqli_result ) { $result->free(); return true; } if ( $result instanceof mysqli_result ) { $result->free(); return true; }
if ( false === $result ) { return false; } if ( false === $result ) { return false; }
return $this->db->affected_rows; return $this->db->affected_rows;
@@ -289,9 +299,23 @@ if ( ! function_exists( 'do_action_ref_array' ) ) {
if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } } if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } }
if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } } if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } }
if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } } if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } }
if ( ! function_exists( 'get_metadata' ) ) {
// Like get_metadata(): the first stored value of a key (single), read from the real meta table.
function get_metadata( string $type, $object_id, string $key = '', bool $single = false ) {
global $wpdb;
$table = $wpdb->{$type . 'meta'};
$id = 'user' === $type ? 'umeta_id' : 'meta_id';
$value = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$type}_id` = %d AND meta_key = %s ORDER BY `{$id}` LIMIT 1", $object_id, $key ) );
if ( $single ) {
return null === $value ? '' : maybe_unserialize( $value );
}
return null === $value ? array() : array( maybe_unserialize( $value ) );
}
}
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } } if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } } if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } } if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } }
if ( ! function_exists( 'wp_unschedule_hook' ) ) { function wp_unschedule_hook( string $hook ): int|false { $GLOBALS['_wp_unscheduled'][] = $hook; return 0; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } } if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } }
if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } } if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } }
if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } } if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } }
@@ -331,6 +355,19 @@ if ( ! function_exists( 'get_term_meta' ) ) { function get_term_meta( int $tid,
if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } } if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } }
if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } } if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } }
if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } } if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } }
if ( ! class_exists( 'WP_Post' ) ) {
final class WP_Post {
public $ID = 0;
public $post_type = 'post';
public $post_status = 'publish';
public $post_date = '0000-00-00 00:00:00';
public function __construct( $post ) {
foreach ( get_object_vars( $post ) as $k => $v ) {
$this->$k = $v;
}
}
}
}
if ( ! function_exists( 'get_post' ) ) { function get_post( $post_id ) { return $GLOBALS['_wp_posts'][ (int) $post_id ] ?? null; } } if ( ! function_exists( 'get_post' ) ) { function get_post( $post_id ) { return $GLOBALS['_wp_posts'][ (int) $post_id ] ?? null; } }
if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } } if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } }
if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } } if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }
@@ -411,6 +448,7 @@ if ( ! function_exists( 'wp_mkdir_p' ) ) {
$GLOBALS['_wp_cache'] = []; $GLOBALS['_wp_cache'] = [];
if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } } if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } }
if ( ! function_exists( 'wp_cache_set_last_changed' ) ) { function wp_cache_set_last_changed( $group ) { $GLOBALS['_wp_last_changed'][ $group ] = (string) hrtime( true ); return $GLOBALS['_wp_last_changed'][ $group ]; } }
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_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } }
if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } } if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } }
if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } } if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }
@@ -418,6 +456,7 @@ if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key,
if ( ! class_exists( 'WP_Query' ) ) { if ( ! class_exists( 'WP_Query' ) ) {
class WP_Query { class WP_Query {
public string $request = '';
private array $vars = []; private array $vars = [];
public function get( string $key, $default = '' ) { return $this->vars[ $key ] ?? $default; } public function get( string $key, $default = '' ) { return $this->vars[ $key ] ?? $default; }
public function set( string $key, $value ): void { $this->vars[ $key ] = $value; } public function set( string $key, $value ): void { $this->vars[ $key ] = $value; }
@@ -516,10 +555,12 @@ if ( ! function_exists( 'wp_insert_comment' ) ) {
if ( ! class_exists( 'WP_CLI' ) ) { if ( ! class_exists( 'WP_CLI' ) ) {
class WP_CLI { class WP_CLI {
public static function log( string $msg ): void {} // Output is kept in $GLOBALS['_wp_cli_output'] so CLI tests can assert it.
public static function warning( string $msg ): void {} public static function log( string $msg ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
public static function success( string $msg ): void {} public static function line( string $msg = '' ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
public static function error( string $msg ): void {} public static function warning( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Warning: ' . $msg; }
public static function success( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Success: ' . $msg; }
public static function error( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Error: ' . $msg; }
public static function add_command( string $name, $class ): void {} public static function add_command( string $name, $class ): void {}
} }
} }
@@ -576,6 +617,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/class-tmdo-rest-api.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.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-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-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-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'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -584,6 +626,7 @@ require_once TMDO_PATH . 'includes/engine/class-tmdo-query-compiler.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-schema-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-registry.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php';
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
@@ -610,6 +653,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.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-postmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php'; require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php';
@@ -643,3 +687,43 @@ if ( ! class_exists( 'TMDO_Listing_Stats' ) ) {
} }
class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' ); class_alias( 'TMDO_Listing_Stats', 'WPDO_Listing_Stats' );
} }
/**
* The WooCommerce product group core registered before 1.2.0 (PR-077 moved
* product meta back to native only). Tests that use products as a routed
* post type register it themselves; the table name wp_itest_wpdo_post_wc_product
* is unchanged.
*/
function tmdo_test_register_wc_product_group(): void {
$enum = static fn( string $key, array $options ): array => array(
'key' => $key,
'type' => 'enum',
'options' => $options,
);
TMDO_Entity_Registry::register_group(
'post',
'wc_product',
array(
array( 'key' => '_price', 'type' => 'decimal', 'searchable' => true ),
array( 'key' => '_regular_price', 'type' => 'decimal', 'searchable' => true ),
array( 'key' => '_sale_price', 'type' => 'decimal', 'searchable' => true ),
array( 'key' => '_stock', 'type' => 'integer', 'searchable' => true ),
array( 'key' => '_stock_status', 'type' => 'enum', 'searchable' => true, 'options' => array( 'instock', 'outofstock', 'onbackorder' ) ),
array( 'key' => '_sku', 'type' => 'text', 'searchable' => true ),
$enum( '_manage_stock', array( 'yes', 'no' ) ),
$enum( '_backorders', array( 'yes', 'no', 'notify' ) ),
$enum( '_sold_individually', array( 'yes', 'no' ) ),
$enum( '_virtual', array( 'yes', 'no' ) ),
$enum( '_downloadable', array( 'yes', 'no' ) ),
array( 'key' => '_tax_class', 'type' => 'text' ),
$enum( '_tax_status', array( 'taxable', 'shipping', 'none' ) ),
array( 'key' => '_download_limit', 'type' => 'integer' ),
array( 'key' => '_download_expiry', 'type' => 'integer' ),
array( 'key' => '_product_version', 'type' => 'text' ),
array( 'key' => '_wc_average_rating', 'type' => 'decimal', 'searchable' => true ),
array( 'key' => '_wc_review_count', 'type' => 'integer' ),
array( 'key' => 'total_sales', 'type' => 'integer', 'searchable' => true ),
),
array( 'post_types' => array( 'product' ) )
);
}
+61
View File
@@ -0,0 +1,61 @@
# 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) |
| `drift` | `TMDO_Drift_Check`: json groups show no false diff; a planted flat mismatch / flat-only value counts exactly once (1.2.0) |
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
## Tools (`tools/`)
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 );
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* Suite: TMDO_Drift_Check on a real site (2.2 PR-070).
*
* - json groups (attachment / nav_menu_item) compare by value: a serialized
* native array and its JSON flat copy are not drift.
* - a flat value planted behind the checker's back shows up as exactly one
* diff / one extra, nothing else moves.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* Run the drift check for one group and put the site's recorded status back.
*
* @param string $entity Entity type.
* @param string $group Group.
* @return array<string, mixed> Report.
*/
function tmdo_live_drift( string $entity, string $group ): array {
$status = get_option( TMDO_Drift_Check::OPTION, null );
try {
return TMDO_Drift_Check::run( $entity, $group );
} finally {
null === $status ? delete_option( TMDO_Drift_Check::OPTION ) : update_option( TMDO_Drift_Check::OPTION, $status );
}
}
TMDO_Live::test(
'drift-check reports no diff for json groups whose values match native',
static function () {
$checked = 0;
foreach ( array( 'attachment', 'nav_menu_item' ) as $group ) {
if ( ! in_array( $group, TMDO_Entity_Migration_Engine::backfillable_groups( 'post' ), true )
|| ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( 'post', $group ) ) ) {
continue;
}
$report = tmdo_live_drift( 'post', $group );
tmdo_live_assert_same( 0, $report['total']['diff'], "post/{$group} diff" );
++$checked;
}
if ( 0 === $checked ) {
throw new TMDO_Live_Skip( 'no attachment / nav_menu_item flat table on this site' );
}
}
);
TMDO_Live::test(
'drift-check counts a planted flat mismatch once and a flat-only value once',
static function () {
global $wpdb;
TMDO_Live_Fixtures::use_modes( array( 'user' => 'disabled' ) );
$field = TMDO_Live_Fixtures::field( 'user', 'login_count' );
$group = $field['group'];
$table = TMDO_Schema_Manager::get_table_name( 'user', $group );
$before = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count'];
$mismatch = TMDO_Live_Fixtures::user();
$flat_only = TMDO_Live_Fixtures::user();
add_user_meta( $mismatch, 'login_count', '5' );
try {
$wpdb->insert( $table, array( 'user_id' => $mismatch, 'login_count' => 6 ) );
$wpdb->insert( $table, array( 'user_id' => $flat_only, 'login_count' => 9 ) );
$after = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count'];
} finally {
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE user_id IN (%d, %d)", $mismatch, $flat_only ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
tmdo_live_assert_same( $before['diff'] + 1, $after['diff'], 'login_count diff +1' );
tmdo_live_assert_same( $before['extra'] + 1, $after['extra'], 'login_count extra +1' );
tmdo_live_assert_same( $before['miss'], $after['miss'], 'login_count miss unchanged' );
}
);
+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" );
}
}
}
);
+583
View File
@@ -0,0 +1,583 @@
<?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 () {
// A text key of wp_core (WooCommerce's _sku left the flat tables in 1.2.0).
TMDO_Live_Fixtures::field( 'post', '_wp_page_template' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
$p = tmdo_live_router_posts(
'post',
array(
'P1' => array( '_wp_page_template' => 'tmdo-live-B1-1' ),
'P2' => array( '_wp_page_template' => 'tmdo-live-B1-10' ),
'P3' => array( '_wp_page_template' => 'tmdo-live-B2-1' ),
)
);
foreach ( array( 'B1-1', 'B1_1' ) as $needle ) {
tmdo_live_router_assert_native(
array(
'post_type' => 'post',
'meta_query' => array( array( 'key' => '_wp_page_template', '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 );
}
);
/**
* HivePress-style sort-only search (rating__order), fields ids, first page.
* DECIMAL(10,1): what the HivePress Rating field (decimals 1) sends.
*
* @param string $order ASC | DESC.
* @return array<string, mixed> WP_Query args.
*/
function tmdo_live_router_rating_sort( string $order ): array {
return array(
'post_type' => 'hp_listing',
'post_status' => 'publish',
'posts_per_page' => 10,
'fields' => 'ids',
'suppress_filters' => false,
'meta_query' => array(
array(
'relation' => 'OR',
'rating__order' => array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'NOT EXISTS' ),
array( 'key' => 'hp_rating', 'type' => 'DECIMAL(10,1)', 'compare' => 'EXISTS' ),
),
),
'orderby' => 'rating__order',
'order' => $order,
);
}
/**
* The native sort key of each post, in order (null without a rating). On the
* whole site many listings share a rating and native orders ties by chance,
* so a page is compared by its keys, not its ids.
*
* @param array<int|string> $ids Post ids.
* @return array<int, string|null>
*/
function tmdo_live_router_rating_keys( array $ids ): array {
global $wpdb;
$ids = array_map( 'intval', $ids );
if ( empty( $ids ) ) {
return array();
}
$rows = $wpdb->get_results(
"SELECT post_id, CAST(meta_value AS DECIMAL(10,1)) AS k FROM {$wpdb->postmeta} WHERE meta_key = 'hp_rating' AND post_id IN (" . implode( ',', $ids ) . ')',
OBJECT_K
);
return array_map( static fn( $id ) => isset( $rows[ $id ] ) ? (string) $rows[ $id ]->k : null, $ids );
}
/**
* Native and routed runs of the same query.
*
* @param array<string, mixed> $args Query args.
* @return array{0:WP_Query, 1:WP_Query}
*/
function tmdo_live_router_native_and_routed( array $args ): array {
$native = new WP_Query( $args );
$router = new TMDO_Post_Query_Router();
$router->register_hooks();
try {
$routed = new WP_Query( $args );
} finally {
tmdo_live_router_unhook( $router );
}
return array( $native, $routed );
}
TMDO_Live::test(
'a sort-only search drives from the flat table when every post of the type has a flat row (2.2 PR-079)',
static function () {
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
TMDO_Post_Query_Router::forget_coverage();
foreach ( array( 'DESC', 'ASC' ) as $order ) {
list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( $order ) );
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), "{$order} sort keys" );
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, "{$order} found_posts" );
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), "{$order} flat-first: " . $routed->request );
}
}
);
TMDO_Live::test(
'a sort-only search keeps the LEFT JOIN while a post of the type has no flat row (2.2 PR-079)',
static function () {
global $wpdb;
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
$bare = TMDO_Live_Fixtures::post( 'hp_listing' );
$wpdb->delete( TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' ), array( 'post_id' => $bare ) );
TMDO_Post_Query_Router::forget_coverage();
try {
list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( 'DESC' ) );
} finally {
TMDO_Post_Query_Router::forget_coverage();
}
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
tmdo_live_assert_true( false === strpos( $routed->request, 'STRAIGHT_JOIN' ), 'not flat-first: ' . $routed->request );
}
);
TMDO_Live::test(
'a post created while the router is active gets its empty flat row (2.2 PR-079)',
static function () {
global $wpdb;
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
$router = new TMDO_Post_Query_Router();
$router->register_hooks();
try {
$id = TMDO_Live_Fixtures::post( 'hp_listing' );
} finally {
tmdo_live_router_unhook( $router );
}
$table = TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' );
tmdo_live_assert_same( '1', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE post_id = %d", $id ) ), 'flat row created' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
);
TMDO_Live::test(
'a flat-first search drops the GROUP BY WordPress adds for a subquery-only tax query (2.2 PR-079)',
static function () {
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
TMDO_Post_Query_Router::forget_coverage();
// Like WooCommerce's product_visibility exclusion on front-end searches: NOT IN
// is a subquery, yet WordPress groups by posts.ID for any tax query.
$args = tmdo_live_router_rating_sort( 'DESC' ) + array(
'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => array( 999999999 ), 'operator' => 'NOT IN' ) ),
);
list( $native, $routed ) = tmdo_live_router_native_and_routed( $args );
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request );
tmdo_live_assert_true( false === stripos( $routed->request, 'GROUP BY' ), 'no GROUP BY: ' . $routed->request );
}
);
TMDO_Live::test(
'a flat-first search keeps the GROUP BY when a tax query joins term relationships (2.2 PR-079)',
static function () {
TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
TMDO_Post_Query_Router::forget_coverage();
$terms = get_terms( array( 'taxonomy' => 'hp_listing_category', 'number' => 2, 'hide_empty' => true, 'fields' => 'ids' ) );
if ( count( (array) $terms ) < 2 ) {
throw new TMDO_Live_Skip( 'needs two used hp_listing_category terms' );
}
$args = tmdo_live_router_rating_sort( 'DESC' ) + array(
'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => $terms, 'operator' => 'IN' ) ),
);
list( $native, $routed ) = tmdo_live_router_native_and_routed( $args );
tmdo_live_assert_same( tmdo_live_router_rating_keys( $native->posts ), tmdo_live_router_rating_keys( $routed->posts ), 'sort keys' );
tmdo_live_assert_same( $native->found_posts, $routed->found_posts, 'found_posts' );
tmdo_live_assert_true( false !== stripos( $routed->request, 'GROUP BY' ), 'GROUP BY kept: ' . $routed->request );
}
);
+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
+62
View File
@@ -0,0 +1,62 @@
<?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 ) ) ); }
// Never wp_cache_flush(): with Redis Object Cache selective flush it is one server-side scan that blocks Redis for every site.
function clear_caches( $id ) { clean_post_cache( $id ); wp_cache_flush_runtime(); }
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 ) ); clean_user_cache( $uid ); wp_cache_flush_runtime();
$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(); clean_user_cache( $uid ); wp_cache_flush_runtime();
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(); clean_post_cache( $lid ); wp_cache_flush_runtime();
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() ) );
+70
View File
@@ -0,0 +1,70 @@
<?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++ ) {
// Fresh routed counts without wp_cache_flush_group() (a Redis-blocking server-side scan).
if ( method_exists( 'TMDO_Post_Query_Router', 'bump_count_version' ) ) {
TMDO_Post_Query_Router::bump_count_version();
} else {
wp_cache_set_last_changed( 'posts' ); // Core < 1.3.0 keyed the counts on posts' last_changed.
}
$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
);
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* The admin settings form saves bridge modes through TMDO_Mode_Manager::set().
* A refused change (unsafe jump, Promotion Guard) used to be dropped silently:
* the page said "saved" while the mode stayed put (2.4.2 PR-135, manual F-2).
*/
class AdminBridgeModeSaveTest extends TestCase {
public static function setUpBeforeClass(): void {
require_once TMDO_PATH . 'admin/class-tmdo-admin.php';
}
protected function setUp(): void {
ini_set( 'error_log', '/dev/null' );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_filter_callbacks'] = array();
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
$GLOBALS['_wp_current_user_id'] = 7;
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_an_accepted_change_is_applied_without_refusals(): void {
$this->modes( array( 'user' => 'disabled' ) );
$refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'dual_write' ) );
$this->assertSame( array(), $refused );
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) );
}
public function test_an_unsafe_jump_is_reported_and_the_mode_kept(): void {
$this->modes( array( 'post' => 'disabled' ) );
$refused = TMDO_Admin::apply_bridge_modes( array( 'post' => 'shadow_read' ) );
$this->assertCount( 1, $refused );
$this->assertStringStartsWith( 'post', $refused[0] );
$this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'post' ) );
}
public function test_empty_or_unknown_modes_are_ignored(): void {
$this->modes( array( 'term' => 'disabled' ) );
$this->assertSame( array(), TMDO_Admin::apply_bridge_modes( array( 'post' => '', 'term' => 'bogus' ) ) );
$this->assertSame( 'disabled', TMDO_Mode_Manager::get( 'term' ) );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_a_promotion_refused_by_the_guard_is_reported(): void {
eval( 'namespace HivePress; class Core {}' );
$this->modes( array( 'user' => 'shadow_read' ) );
$refused = TMDO_Admin::apply_bridge_modes( array( 'user' => 'aeav_only' ) );
$this->assertCount( 1, $refused );
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) );
}
/** The full-page form redirects after saving: refusals wait in a per-user transient, shown once. */
public function test_refusals_are_kept_for_the_next_page_view_only(): void {
TMDO_Admin::remember_bridge_mode_refusals( array( 'post:refused' ) );
$this->assertSame( array( 'post:refused' ), TMDO_Admin::pull_bridge_mode_refusals() );
$this->assertSame( array(), TMDO_Admin::pull_bridge_mode_refusals() );
}
}

Some files were not shown because too many files have changed in this diff Show More