Compare commits

..

115 Commits

Author SHA1 Message Date
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
wpdev b917e45c8c chore(2meet-data-optimizer): make the hardening branch pass PHPStan and PHPCS
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 14s
Tests / PHP Lint (pull_request) Successful in 1m0s
Tests / PHPCS (pull_request) Successful in 1m15s
Tests / Unit Tests (pull_request) Successful in 57s
Tests / Integration Tests (pull_request) Successful in 1m14s
Tests / PHPStan (pull_request) Successful in 34s
- CI PHPStan (level 6, baseline for pre-existing debt) failed on 35 errors in
  code added on hardening/2.0: missing iterable value types (PHPDoc only), a
  redundant method_exists() in uninstall.php, and the new WP-CLI commands
  registered as array('Class', 'instanceMethod') — now array(new Class(), ...)
- Local runs had been masked by a PHPStan tmpDir shared with the act CI runner
  (cached container pointing at a removed phar); use a private tmpDir
- Verify: phpstan (private tmpDir) no errors, phpcs clean on changed files,
  phpunit Unit 658 OK; dev22 (running this branch) CLI post-status-sync /
  bridge-restore-native dry runs OK
2026-09-26 07:45:05 +08:00
wpdev 08492dd795 fix(2meet-data-optimizer): compare empty strings correctly when retiring a group
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 14s
Tests / Unit Tests (pull_request) Successful in 33s
Tests / PHP Lint (pull_request) Successful in 36s
Tests / Integration Tests (pull_request) Successful in 57s
Tests / PHPCS (pull_request) Successful in 35s
Tests / PHPStan (pull_request) Failing after 50s
- retire() read native meta with wpdb::get_col(), which (via get_var())
  returns null for an empty string, so a flat '' written back as '' was
  counted as a mismatch and blocked the retirement. It now reads raw rows
  with get_results().
- The integration test wpdb stub's get_var() / get_col() now read '' as
  null like the real wpdb, so tests see WordPress behaviour.
- Found writing back the sso group on dev21 / dev22 (picture_url = '').
- Verify: core unit 658 / integration 480 green, phpcs clean
- PR: PR-042 follow-up
2026-09-26 04:24:14 +08:00
wpdev ecf4ff4938 refactor(2meet-data-optimizer): retire the SSO AddOn recommendation
- missing_integration_addons() no longer asks for the SSO AddOn when
  2meet-hub-core / 2meet-spoke-sso are active: they register their own
  tables and the `sso` values belong in usermeta (benchmark: no
  measurable gain from the flat table for by-user-id access).
- `sso` joins the deprecated AddOns; for hub / spoke / sso the notice says
  they go without replacement and to run
  `wp tmdo bridge-restore-native user --group=sso --retire` first.
- Verify: core unit 658 / integration 479 green, phpcs clean
- PR: PR-043
2026-09-26 04:07:40 +08:00
wpdev 44a188b7ee feat(2meet-data-optimizer): group-scoped native write-back and group retirement
- TMDO_Native_Restorer::run() takes an optional group; the CLI gains
  `bridge-restore-native <entity> --group=<g>`.
- New retire(): once native meta holds every non-NULL value of a group's
  flat table (checked against the persisted field definitions), forget
  its registry_meta row and rename the table to <table>_retired_<Ymd>, so
  orphan restore no longer routes its keys to the flat table. Refused
  while a plugin still registers the group or any value differs.
  CLI: `--group=<g> --retire`.
- For retiring the user `sso` group (SSO AddOn deprecation).
- Verify: core unit 659 / integration 479 green, phpcs clean
- PR: PR-042
2026-09-26 04:06:31 +08:00
wpdev c70dc99bf9 refactor(2meet-data-optimizer): stop registering the sso group in core
- TMDO_Member_Fields no longer registers user/sso; the definition moved
  unchanged (hash 03543f19) to 2meet-data-optimizer-sso-addon.
- missing_integration_addons(): 2meet-hub-core or 2meet-spoke-sso without
  the SSO AddOn is reported (admin notice + Site Health).
- The deprecated-AddOn notice points hub / spoke users to the SSO AddOn.
- Sites must activate the SSO AddOn before this ships (PR-041); aeav_only
  sites without it fall back to orphan-group restore.
- Verify: core unit 659 / integration 475, SSO 6, HP 142/49, WC 10/22
  green, phpcs clean
- PR: PR-040
2026-09-26 03:39:58 +08:00
wpdev 10671e7b78 perf(2meet-data-optimizer): cache found-rows counts for routed queries
- TMDO_Post_Query_Router drops SQL_CALC_FOUND_ROWS from routed searches
  (posts_request) and answers found_posts_query from a COUNT(*) of the
  same query, cached in the object cache under posts' last_changed.
- Hook Bus flat post writes and deletes move posts' last_changed: in
  aeav_only they skip update_metadata()'s own hooks, so query caches
  would otherwise outlive the write.
- Bench (1M): cached page 8 ms (was 694 / 363 ms for scenarios 1 / 2);
  after invalidation 8 ms + COUNT 608 / 344 ms, on par with before.
- REST X-WP-Total COUNT is on the frozen Hot zone path (ADR-003): not
  changed.
- Verify: core unit 659 / integration 475, HP 142/49, WC 10/22 green,
  phpcs clean
- PR: PR-030
2026-09-26 01:44:45 +08:00
wpdev 754f904e3b perf(2meet-data-optimizer): route status and date ordering to entity tables
- TMDO_Post_Query_Router: when a group's post_status / post_date copies
  are ready and none is NULL (one index probe per group per request), a
  single-post-type search with explicit statuses and date ordering INNER
  JOINs the flat table, filters the copied status, orders by the copied
  post_date and skips GROUP BY. Otherwise the query keeps its old shape.
- New posts_orderby filter; bench gains an `entity+copy` variant.
- Bench (1M, with SQL_CALC_FOUND_ROWS): scenario 1 p95 3,739 -> 773 ms
  (p50 670 -> 770); scenario 2 p50 356 -> 448 ms. Without found-rows the
  same shape runs in 8 ms (PR-030).
- Verify: core unit 659 / integration 471 green, phpcs clean
- PR: PR-029
2026-09-26 01:41:07 +08:00
wpdev aeca12b41d feat(2meet-data-optimizer): store post_status and post_date copies in post entity tables
- TMDO_Post_Status_Sync: post group tables get post_status / post_date
  columns (posts' collation) and an (post_status, post_date) index.
  Brand-new empty tables get them on creation; existing tables only via
  `wp tmdo post-status-sync [--dry-run] [--batch]`, never as DDL on a
  populated table in a web request. Per-group state in
  tmdo_post_status_columns ('columns' | 'ready').
- transition_post_status refreshes the copies; Hook Bus upserts that
  create a post row fill them in (existing rows keep theirs).
- Bench (1M rows): add columns 0.05 s, index 5.3 s, batched backfill 32 s,
  0 mismatches afterwards.
- Verify: core unit 659 / integration 466 green, phpcs clean
- PR: PR-028
2026-09-26 01:25:20 +08:00
wpdev f4eee2338b feat(2meet-data-optimizer): flag new Hot zone registrations as deprecated
- ADR-003 step 4 (core side): TMDO_Schema_Registry::register() raises
  _doing_it_wrong() the first time each provider registers a zone=hot
  field in a request (zone omitted defaults to hot). Fields still
  register, so existing hot data stays readable.
- Cold / warm / archive registrations are unaffected.
- Test bootstrap's _doing_it_wrong stub now records calls.
- Verify: core unit 658 / integration 459, HP 142/49, WC 10/22 green,
  phpcs clean
- PR: PR-038
2026-09-26 01:16:05 +08:00
wpdev 65c75dfc8d feat(2meet-data-optimizer): support composite indexes on entity groups
- register_group() accepts $args['indexes'] (name => ordered keys); indexes on
  unknown keys are dropped. Kept outside the field definition, so schema
  hashes are unchanged.
- Schema_Manager::ensure_group_indexes() adds missing cidx_* indexes to the
  existing flat table in place; the applied definition is remembered in
  tmdo_entity_indexes so later requests skip SHOW INDEX.
- Bench (1M listings, scenario 2): Entity p50 337 ms -> 278 ms with
  (hp_verified, hp_price), on par with Hot (269 ms).
- Verify: core unit 656 / integration 459 green, phpcs clean
- PR: PR-027
2026-09-26 01:05:23 +08:00
wpdev d6b2e417f4 docs(2meet-data-optimizer): decide hot table vs entity table consolidation (ADR-003)
- Compare the Hot zone and Entity bridge (capabilities, 1M-listing benchmark, per-site usage: no 2meet-DO site uses the Hot zone; only legacy dev34 / dev10 hold Hot copies of postmeta)
- Decision (user, 2026-09-26): converge on Entity; freeze the Hot zone, skip PR-026, keep PR-027, move Hot-only HivePress fields into new Entity groups, stop Hot registrations
- PR: PR-025
2026-09-26 00:49:20 +08:00
wpdev d86c0a7bac feat(2meet-data-optimizer): write flat-table data back to native meta
- TMDO_Native_Restorer copies an entity's flat-table values into its native meta table so the value each key reads today is what native returns afterwards: the owning group (by post type for posts) wins, values are stored as WP stores them, NULL removes the native row
- Resumable per-group id cursor, catch-up pass for rows updated during the run, optional demote to dual_write; CLI wp tmdo bridge-restore-native <entity> [--dry-run] [--batch=<n>] [--demote]
- Verify: NativeRestorerTest (5, user) + NativeRestorerPostTest (2, scoped vs legacy routing); core unit 652 / integration 456, HP 148, WC 10 OK; scale estimate dev21 user ~5.1M / post ~4.0M flat rows
- PR: PR-031
2026-09-26 00:44:53 +08:00
wpdev c84d2409b7 feat(2meet-data-optimizer): warn when HivePress / WooCommerce run without their AddOn
- TMDO_Compatibility::missing_integration_addons() reports active HivePress / WooCommerce whose AddOn bootstrap class is absent
- Surface it as Site Health test wpdo_missing_integration_addons (also on the admin Doctor tab) and an administrator notice (TMDO_Missing_Addons_Notice)
- Verify: MissingIntegrationAddonsTest (5, class stand-ins in separate processes); core unit 651 / integration 449, HP 148, WC 10 OK; read-only check: dev20/21 HivePress+WooCommerce, dev22 HivePress, others clean
- PR: PR-035
2026-09-26 00:36:02 +08:00
wpdev 3d650eb848 docs(2meet-data-optimizer): partner self-registration guide and deprecated AddOn notice
- Add docs/PARTNER-REGISTRATION.md: TMDO_Anti_EAV_Aware bridge setup (plugins_loaded:3), table and entity-group registration with post_types scopes, frozen definitions, and the AddOn → self-registration migration checked with [COLLISION]/[ORPHAN]
- TMDO_Deprecated_Addons_Notice warns administrators while any of the 8 in-house partner AddOns is active (site or network)
- Trait register_entity_fields() forwards an optional $args (post_types) to the registry
- Verify: DeprecatedAddonsNoticeTest (4); core unit 645 / integration 449, HP 148, WC 10 OK
- PR: PR-021
2026-09-26 00:31:34 +08:00
wpdev 0603537f0a refactor(2meet-data-optimizer): resolve post_type/group/table mappings through the registry
- TMDO_Post_Fields::post_type_for_group() / groups_for_post_type() derive HivePress-style groups from their registry post_type scope (core keeps its own attachment / wc_product / nav_menu_item map); the three copy-pasted switches and the query router's POST_TYPE_GROUP_MAP use them
- Term/comment backfill, shadow verifier and CLI take their groups from the registry (TMDO_Term_Comment_Backfill::registered_groups()); installer drops the hp_* entries already covered by its table patterns; REST listings default post type is filterable (tmdo_rest_default_post_type, default 'post')
- Verify: NoHpMappingsTest guard + mapping/default tests; core unit 640 / integration 449, HP 146/49, WC 10/22 OK
- PR: PR-020e
2026-09-26 00:22:20 +08:00
wpdev 8a1c9c0353 refactor(2meet-data-optimizer): remove HPCT legacy from core
- Feature flags keep only the live AddOn modules (wc_orders, latepoint) as ADDON_MODULES / addon_modules(); drop map_hpct_status() and the 7 HivePress HPCT modules whose interceptors were removed in PR-014
- Remove HPCT detection/notice (Compatibility, core), the admin HPCT import tab and status row, CLI import-hpct, the 7 advisor rules for dead modules, and the removed interceptors from COEXIST_WHITELIST
- Verify: NoHpctLegacyTest guard; core unit 635 / integration 449, HP 145/49, WC 10/22 OK; all sites have every module idle and never imported HPCT
- PR: PR-020c
2026-09-26 00:14:18 +08:00
wpdev 1eedaa9b3c refactor(2meet-data-optimizer): stop registering HivePress entity groups in core
- Drop hp_listing_core / hp_request_core / hp_vendor_core from TMDO_Post_Fields and hp_user from TMDO_Member_Fields; the HivePress AddOn registers identical definitions (PR-019/020a/033b)
- Tests: core asserts it registers none of them; HP-specific core tests removed (covered by the AddOn); SyncBridgeEntityGuardTest registers its own hp_price group
- Verify: core unit 633 / integration 449, HP 145/49, WC 10/22 OK
- Deploy: activate the HivePress AddOn on dev20/21/22 first
- PR: PR-020b
2026-09-26 00:07:31 +08:00
wpdev e0683a19b1 feat(2meet-data-optimizer): reconcile post rows written to the wrong group table
- TMDO_Post_Group_Reconciler copies non-NULL values that legacy routing stored in another group's table into the post type's own group (NULL/missing legacy values keep the scoped value), resumable via a cursor, with a catch-up pass for rows updated during the run, then switches on tmdo_post_scoped_routing
- CLI: wp tmdo post-reconcile-groups [--dry-run] [--batch=<n>]; registry exposes get_group_post_types()
- Verify: PostGroupReconcilerPlanTest (4), PostGroupReconcilerTest (6); core unit 635 / integration 449, HP 145/49, WC 10/22 OK; read-only dry-run: dev20 320 values / 0 overwrites, dev21/22 none
- PR: PR-034
2026-09-26 00:04:38 +08:00
wpdev 3f4fe52e1b fix(2meet-data-optimizer): route post entity keys by post_type
- Entity groups can declare post_types; get_field() resolves a post's key against its own type's groups first, then unscoped ones (legacy last-wins lookup kept for callers without a post type)
- Hook Bus and the post query router pass the post type, so a listing's hp_rating / hp_status / hp_verified map to hp_listing_core instead of the vendor/request tables; core HP groups declare their post types (schema hashes unchanged)
- Behind tmdo_post_scoped_routing: on for sites without post flat rows, off where rows may sit in the wrong table until PR-034 reconciles them (dry-run: dev20–22 off, dev23/24/30/31/40 on)
- Verify: EntityRegistryScopeTest (8), PostScopedRoutingTest (4); core unit 630 / integration 443, HP 144/49, WC 10/22 OK
- PR: PR-033
2026-09-25 23:55:49 +08:00
wpdev 1653011f4f feat(2meet-data-optimizer): guard orphaned flat-table groups
- On init:1, re-register aeav_only groups whose flat table exists and whose definition is in wpdo_registry_meta but no active plugin registers them, so their data stays readable (routing only, never DDL)
- Keys owned by a live group, or with rows in native meta (newer data written while orphaned), are left alone; the native-meta check is cached for a day
- Surface restored groups via Site Health (wpdo_orphaned_entity_groups), the admin Doctor tab, and wp wpdo doctor
- Verify: EntityRegistryRestoreTest (9), OrphanGroupRestoreTest (7); core unit 622 / integration 439, HP 141/49, WC 10/22 OK; dry-run on dev20–22 restores 16/17/4 groups, keeps hp_taxonomy hp_sort_order/hp_icon on native
- PR: PR-018
2026-09-25 23:38:12 +08:00
wpdev e9d8696c93 fix(2meet-data-optimizer): report entity group name collisions instead of silently dropping
- register_group() keeps the first definition but records a field diff (added/removed/changed) when the same entity/group is re-registered differently; identical re-registration stays silent
- Surface collisions via Site Health (wpdo_entity_group_collisions), the admin Doctor tab, and wp wpdo doctor; warning log throttled to once a day
- Verify: EntityRegistryCollisionTest (7); core unit 613 / integration 432, HP 141/49, WC 10/22 OK
- PR: PR-017
2026-09-25 23:27:05 +08:00
wpdev 07db7734cf refactor(2meet-data-optimizer): delete dead HivePress defaults and hardcoded partner list
- Remove never-called register_hivepress_defaults() and core boot probes for AddOn classes that load at plugins_loaded:3/:6 and self-register (8 partner list entries, WC orders interceptor, HP transient filter)
- Keep core-owned TMDO_Member_Fields / TMDO_Post_Fields registration unchanged
- Verify: CoreBoundaryTest guard added; core unit 606 / integration 432, HP 141/49, WC 10/22 OK
- PR: PR-016
2026-09-25 23:19:22 +08:00
wpdev 9dc4516ca0 fix(2meet-data-optimizer): filter REST listings collection by post_status and post_type
- GET /wpdo/v1/listings rejects unregistered or non-viewable post types (400) and joins posts so Zone A only lists published posts of that type
- WP_Query fallback now returns only show_in_rest hot columns, matching the Zone A path
- Verify: core unit 605 / integration 432 OK (new RestApi tests); 1M bench: page 7ms, total COUNT 0.12s → 1.7s (tracked in PR-030)
- PR: PR-015
2026-09-25 23:10:46 +08:00
wpdev b407f235ac fix(2meet-data-optimizer): refuse uninstall while data lives only in flat tables
- uninstall.php checks every site first and wp_die()s (keeping tables,
  options and plugin files) on aeav_only modes, misc rows, or zone
  modules past cutover; a failed bootstrap also refuses
- Multisite now checks and cleans every site: network activation is always
  false at uninstall, so only the main site was being cleaned before
- Drop the inline fallback DROP loop, unreachable now that a missing
  helper refuses instead

Verify: UninstallGuardTest (8 tests incl. running uninstall.php single and
  multisite); unit 602 OK, integration 429 OK
PR: PR-008
2026-09-25 22:16:02 +08:00
wpdev fc0f018a94 fix(2meet-data-optimizer): stop auto-activating entity bridge on install
- install() no longer switches the Hook Bus on or puts user/term/comment
  into dual_write; the now-unused maybe_autoactivate_entity_bridge() is gone
- Hook Bus and Mode_Manager defaults are off/disabled; sites with stored
  modes (incl. aeav_only) keep them since only unset keys use defaults
- Guard run_dbdelta()'s upgrade.php require like install_v2_tables() so
  install() can run under the integration test stub

Verify: InstallerV2Test::test_install_does_not_enable_entity_bridge,
  ModeManagerDefaultsTest; unit 602 OK, integration 421 OK
PR: PR-007
2026-09-25 21:48:23 +08:00
wpdev 151aadd661 fix(2meet-data-optimizer): default misc bucket and garbage filter to off
- Both term/comment filters now default to disabled for new installs, so
  activating the plugin no longer reroutes or drops other plugins' meta
- One-time maybe_upgrade() migration keeps the misc bucket on for sites
  that already hold misc rows and never set the toggle (data stays readable)
- Admin toggles show the same defaults as the filters

Verify: phpunit-integration --filter 'defaults_false|preserve_default';
  unit 600 OK, integration 420 OK
PR: PR-006
2026-09-25 21:39:38 +08:00
wpdev d3b72bd1af test(2meet-data-optimizer): add scale benchmark report (native vs hot vs entity)
- Benchmark 7 scenarios at 100k vendors / 1M listings with SQL shaped like
  WP_Query, TMDO_Query_Router and TMDO_Post_Query_Router
- Native meta_query takes 14-17s; flat tables bring searches to 0.3-0.6s
- Hot table lacks an hp_vendor index (3.4s); entity lacks composite indexes

Verify: TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/run.php --iterations=20
PR: PR-005
2026-09-25 21:29:58 +08:00
wpdev 8fc1dc1f1c test(2meet-data-optimizer): add scale seeder for 100k vendors / 1M listings
- Seed a HivePress-shaped dataset three ways (native meta, entity tables,
  hot table) with flat tables built by TMDO's own schema code
- Refuse any database not named *_bench before the bootstrap connects;
  bootstrap now honours a pre-defined ABSPATH for the wp-admin stub
- Full scale seeds 9.5M postmeta rows in 7m25s; 0 value mismatches

Verify: TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/seed.php
  --vendors=100000 --listings=1000000; unit 600 OK, integration 416 OK
PR: PR-004
2026-09-25 20:55:25 +08:00
162 changed files with 15614 additions and 1982 deletions
+14 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer
* Plugin URI: https://2meet.io/2meet-data-optimizer
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
* Version: 1.0.2
* Version: 1.4.3
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// ── Constants ──────────────────────────────────────────────────────────────
define( 'TMDO_VERSION', '1.0.2' );
define( 'TMDO_VERSION', '1.4.3' );
define( 'TMDO_DB_VERSION', '2.1.0' );
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
@@ -234,6 +234,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// ── v2.0.0 Engine + Adapters + Modules ───────────────────────────────────
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -258,14 +259,19 @@ require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-cleanup.php
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.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-native-restorer.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) );
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php';
require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php';
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-user.php';
@@ -285,6 +291,12 @@ if ( is_admin() ) {
TMDO_Setup_Wizard::register();
require_once TMDO_PATH . 'admin/class-tmdo-dashboard-widget.php';
TMDO_Dashboard_Widget::register();
require_once TMDO_PATH . 'admin/class-tmdo-deprecated-addons-notice.php';
TMDO_Deprecated_Addons_Notice::register();
require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php';
TMDO_Missing_Addons_Notice::register();
require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php';
TMDO_Promotion_Guard_Notice::register();
}
// ── WP-CLI ─────────────────────────────────────────────────────────────────
+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 設定選項保護
**性質**:防禦性修正 + 文件。無 schema 變更,無破壞性變更。
@@ -266,3 +601,11 @@ Versioning follows [Semantic Versioning](https://semver.org/).
- PHPCS 套件 + 0/0 達成
- 打包腳本 10 終檢驗收
- 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
`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)
+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 {string} message
@@ -56,6 +56,9 @@
statusEl.style.color = isError ? 'var(--color-error, #b03d3d)' : 'var(--color-success, #5a8c5a)';
statusEl.classList.add( 'visible' );
clearTimeout( statusEl._wpdo_timer );
if ( isError ) {
return;
}
statusEl._wpdo_timer = setTimeout( () => {
statusEl.classList.remove( 'visible' );
}, 2500 );
@@ -92,7 +95,7 @@
if ( data.success ) {
showStatus( statusEl, i18n.saved, false );
} else {
showStatus( statusEl, i18n.error, true );
showStatus( statusEl, ( data.data && data.data.message ) || i18n.error, true );
}
} )
.catch( () => {
+122 -140
View File
@@ -89,13 +89,16 @@ class TMDO_Admin {
* Register the admin menu page under Tools.
*/
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' ),
'manage_options',
self::MENU_SLUG,
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() ) {
return;
}
@@ -394,17 +398,7 @@ class TMDO_Admin {
// 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 );
if ( class_exists( 'TMDO_Mode_Manager' ) ) {
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();
}
self::remember_bridge_mode_refusals( self::apply_bridge_modes( self::posted_bridge_modes() ) );
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
TMDO_Hook_Bus_Bridge::reset_cache();
}
@@ -556,11 +550,11 @@ class TMDO_Admin {
exit;
}
// v2.10.0: Post Migration Wizard — backfill all 7 groups.
// v2.10.0: Post Migration Wizard — backfill every registered post group.
if ( isset( $_POST['wpdo_post_backfill_all'] ) && check_admin_referer( 'wpdo_post_backfill_all' ) && class_exists( 'TMDO_Post_Migration' ) ) {
$total_migrated = 0;
$errors = array();
foreach ( array( 'wp_core', 'attachment', 'wc_product', 'hp_listing_core', 'hp_request_core', 'hp_vendor_core', 'nav_menu_item' ) as $group ) {
foreach ( TMDO_Post_Migration::backfillable_groups() as $group ) {
try {
$result = TMDO_Post_Migration::backfill_group( $group );
$total_migrated += (int) $result['migrated'];
@@ -752,6 +746,15 @@ class TMDO_Admin {
);
exit;
}
}
/**
* Render the admin page.
*/
public static function render_page(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
$tabs = array(
'dashboard' => __( '儀表板', '2meet-data-optimizer' ),
@@ -774,7 +777,7 @@ class TMDO_Admin {
'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.
if ( in_array( $tab, array( 'sop', 'migration' ), true ) ) {
$tab = 'entity-bridge';
@@ -783,11 +786,6 @@ class TMDO_Admin {
$tab = 'dashboard';
}
// Show HPCT Import tab only when HPCT is detected.
if ( TMDO_Compatibility::should_show_hpct_notice() ) {
$tabs['hpct-import'] = __( 'HPCT 匯入', '2meet-data-optimizer' );
}
// v2.8.1: red-dot badge on tabs that need operator attention.
$needs_dot = array();
if ( class_exists( 'TMDO_Migration_Orchestrator' ) ) {
@@ -811,7 +809,7 @@ class TMDO_Admin {
),
'wizards' => array(
'label' => esc_html__( '🧙 遷移精靈', '2meet-data-optimizer' ),
'tabs' => array( 'migration-wizard', 'post-migration-wizard', 'hpct-import' ),
'tabs' => array( 'migration-wizard', 'post-migration-wizard' ),
),
'stress' => array(
'label' => esc_html__( '⚡ 壓力測試', '2meet-data-optimizer' ),
@@ -851,8 +849,8 @@ class TMDO_Admin {
<span class="wpdo-tab-group-label" aria-hidden="true"><?php echo esc_html( $group['label'] ); ?></span>
<?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 ) ) ); ?>"
class="nav-tab <?php echo $tab === $slug ? 'nav-tab-active' : ''; ?>"
<?php echo $tab === $slug ? 'aria-current="page"' : ''; ?>>
class="nav-tab <?php echo esc_attr( $tab === $slug ? 'nav-tab-active' : '' ); ?>"
aria-current="<?php echo esc_attr( $tab === $slug ? 'page' : 'false' ); ?>">
<?php echo esc_html( $tabs[ $slug ] ); ?>
<?php if ( isset( $needs_dot[ $slug ] ) ) : ?>
<span class="wpdo-tab-dot" title="
@@ -939,9 +937,6 @@ class TMDO_Admin {
case 'rest-api':
self::render_rest_api();
break;
case 'hpct-import':
self::render_hpct_import();
break;
case 'hivepress':
if ( class_exists( 'TMDO_Admin_HivePress' ) ) {
TMDO_Admin_HivePress::render();
@@ -1107,7 +1102,7 @@ class TMDO_Admin {
<tbody>
<tr>
<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>
<th><?php esc_html_e( '外掛版本', '2meet-data-optimizer' ); ?></th>
@@ -1121,24 +1116,14 @@ class TMDO_Admin {
<th><?php esc_html_e( 'HivePress', '2meet-data-optimizer' ); ?></th>
<td><?php echo wp_kses_post( $compat['hivepress'] ? '<span class="wpdo-badge wpdo-badge-ok">Active</span>' : '<span class="wpdo-badge">N/A</span>' ); ?></td>
</tr>
<tr>
<th><?php esc_html_e( 'HP Custom Tables', '2meet-data-optimizer' ); ?></th>
<td>
<?php
if ( $compat['hpct_active'] && $compat['hpct_imported'] ) {
echo '<span class="wpdo-badge wpdo-badge-ok">Imported</span>';
} elseif ( $compat['hpct_active'] ) {
echo '<span class="wpdo-badge wpdo-badge-warn">Needs Import</span>';
} else {
echo '<span class="wpdo-badge">N/A</span>';
}
?>
</td>
</tr>
<tr>
<th><?php esc_html_e( 'Object Cache', '2meet-data-optimizer' ); ?></th>
<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'] ) : ?>
<span class="wpdo-badge wpdo-badge-ok">Flush Support</span>
<?php endif; ?>
@@ -1169,11 +1154,11 @@ class TMDO_Admin {
<h2><?php esc_html_e( '模組狀態', '2meet-data-optimizer' ); ?></h2>
<div class="wpdo-grid">
<div>
<h3>HPCT Modules</h3>
<h3>AddOn Modules</h3>
<table class="widefat striped">
<thead><tr><th><?php esc_html_e( '模組', '2meet-data-optimizer' ); ?></th><th><?php esc_html_e( '狀態', '2meet-data-optimizer' ); ?></th></tr></thead>
<tbody>
<?php foreach ( TMDO_Feature_Flags::hpct_modules() as $module => $state ) : ?>
<?php foreach ( TMDO_Feature_Flags::addon_modules() as $module => $state ) : ?>
<tr>
<td><code><?php echo esc_html( $module ); ?></code></td>
<td><span class="wpdo-state wpdo-state-<?php echo esc_attr( $state ); ?>"><?php echo esc_html( $state ); ?></span></td>
@@ -1748,13 +1733,13 @@ class TMDO_Admin {
↑ <?php esc_html_e( '升級模式', '2meet-data-optimizer' ); ?>
</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-entity-type="<?php echo esc_attr( $etype ); ?>"
data-prev-mode="<?php echo esc_attr( $prev_mode ?? '' ); ?>"
<?php disabled( ! $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' ); ?>
</button>
</div>
@@ -2059,7 +2044,7 @@ class TMDO_Admin {
</div>
<!-- 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>
<div class="wpdo-bar-section">
<div class="wpdo-bar-header">
@@ -2093,7 +2078,7 @@ class TMDO_Admin {
</div>
<!-- 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>
<div id="wpdo-st-bench-content">
<?php if ( ! empty( $state['benchmark'] ) ) : ?>
@@ -2717,79 +2702,6 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
<?php
}
/**
* HPCT Import tab: preview and execute import.
*/
private static function render_hpct_import(): void {
// HPCT is a HivePress-family plugin; the importer ships in that AddOn.
if ( ! class_exists( 'TMDO_HPCT_Import' ) ) {
echo '<div class="notice notice-warning"><p>'
. esc_html__( 'HPCT 匯入需要啟用 2meet-data-optimizer-hivepress-addon。', '2meet-data-optimizer' )
. '</p></div>';
return;
}
// Handle import action.
if ( isset( $_POST['wpdo_run_import'] ) && check_admin_referer( 'wpdo_hpct_import' ) ) {
$result = TMDO_HPCT_Import::run();
if ( is_wp_error( $result ) ) {
echo '<div class="notice notice-error"><p>' . esc_html( $result->get_error_message() ) . '</p></div>';
} else {
echo '<div class="notice notice-success"><p>' . esc_html__( 'HPCT 匯入成功!建議停用 HP Custom Tables 外掛。', '2meet-data-optimizer' ) . '</p></div>';
}
}
$can_import = TMDO_HPCT_Import::can_import();
$is_imported = TMDO_HPCT_Import::is_imported();
?>
<h2><?php esc_html_e( 'HP Custom Tables 匯入', '2meet-data-optimizer' ); ?></h2>
<?php if ( $is_imported ) : ?>
<div class="notice notice-success inline">
<p><?php esc_html_e( 'HPCT 設定已匯入完成。如果 HP Custom Tables 外掛仍然啟用,建議停用它。', '2meet-data-optimizer' ); ?></p>
</div>
<?php elseif ( $can_import ) : ?>
<p class="description"><?php esc_html_e( '偵測到 HP Custom Tables 外掛。匯入會將 HPCT 的模組狀態和遷移記錄複製到 WPDO,然後由 WPDO 接管所有攔截器。', '2meet-data-optimizer' ); ?></p>
<?php
$preview_data = TMDO_HPCT_Import::preview();
if ( ! empty( $preview_data['modules'] ) ) :
$preview = $preview_data['modules'];
?>
<h3><?php esc_html_e( '匯入預覽', '2meet-data-optimizer' ); ?></h3>
<table class="widefat striped wpdo-table--narrow">
<thead>
<tr>
<th><?php esc_html_e( 'HPCT 模組', '2meet-data-optimizer' ); ?></th>
<th><?php esc_html_e( 'HPCT 狀態', '2meet-data-optimizer' ); ?></th>
<th><?php esc_html_e( 'WPDO 狀態', '2meet-data-optimizer' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $preview as $item ) : ?>
<tr>
<td><code><?php echo esc_html( $item['module'] ); ?></code></td>
<td><?php echo esc_html( $item['hpct_status'] ); ?></td>
<td><span class="wpdo-state wpdo-state-<?php echo esc_attr( $item['wpdo_state'] ); ?>"><?php echo esc_html( $item['wpdo_state'] ); ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<form method="post" class="wpdo-mt-3">
<?php wp_nonce_field( 'wpdo_hpct_import' ); ?>
<?php submit_button( __( '執行匯入', '2meet-data-optimizer' ), 'primary', 'wpdo_run_import', false ); ?>
</form>
<?php endif; ?>
<?php else : ?>
<div class="notice notice-info inline">
<p><?php esc_html_e( '未偵測到 HP Custom Tables 外掛,或已完成匯入。', '2meet-data-optimizer' ); ?></p>
</div>
<?php endif; ?>
<?php
}
// ── AJAX handler ──────────────────────────────────────────────────────
/**
@@ -2990,7 +2902,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
?>
<h2><?php esc_html_e( '健康檢查(Doctor)', '2meet-data-optimizer' ); ?></h2>
<p class="description">
<?php esc_html_e( '7 項自我診斷檢查:schema 完整性、錯誤預算、hook 衝突、autoload 大小、postmeta 爆量、orphan zone rows、缺少快照。', '2meet-data-optimizer' ); ?>
<?php esc_html_e( '10 項自我診斷檢查:schema 完整性、錯誤預算、hook 衝突、autoload 大小、postmeta 爆量、orphan zone rows、缺少快照、實體群組名稱衝突、孤兒實體群組、缺少對應 AddOn。', '2meet-data-optimizer' ); ?>
</p>
<p>
<a class="button" href="<?php echo esc_url( admin_url( 'site-health.php' ) ); ?>">
@@ -3011,6 +2923,9 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
'wpdo_postmeta_explosion' => array( 'check_postmeta_explosion', __( 'wp_postmeta 爆量', '2meet-data-optimizer' ) ),
'wpdo_orphan_zone_rows' => array( 'check_orphan_zone_rows', __( 'Orphan zone rows', '2meet-data-optimizer' ) ),
'wpdo_missing_snapshot' => array( 'check_missing_snapshot', __( '缺少最近快照', '2meet-data-optimizer' ) ),
'wpdo_entity_group_collisions' => array( 'check_entity_group_collisions', __( '實體群組名稱衝突', '2meet-data-optimizer' ) ),
'wpdo_orphaned_entity_groups' => array( 'check_orphaned_entity_groups', __( '孤兒實體群組', '2meet-data-optimizer' ) ),
'wpdo_missing_integration_addons' => array( 'check_missing_integration_addons', __( '缺少對應 AddOn', '2meet-data-optimizer' ) ),
);
?>
<table class="widefat striped">
@@ -3219,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
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();
$email = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::recipient() : '';
$throttle = class_exists( 'TMDO_Email_Notifier' ) ? TMDO_Email_Notifier::throttle_hours() : 24;
@@ -3490,7 +3408,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
<?php
// v2.12.1: Term + Comment garbage write-time filter toggle + status.
$tcg_enabled = (bool) get_option( 'wpdo_term_comment_garbage_filter_enabled', '1' );
$tcg_enabled = (bool) get_option( 'wpdo_term_comment_garbage_filter_enabled', '0' );
$tcg_drops_24h = class_exists( 'TMDO_Term_Comment_Garbage_Filter' )
? TMDO_Term_Comment_Garbage_Filter::get_drop_count_24h()
: 0;
@@ -3653,7 +3571,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
<?php
// v2.12.4: Term + Comment misc bucket toggle + status.
$misc_enabled = (bool) get_option( 'wpdo_term_comment_misc_bucket_enabled', '1' );
$misc_enabled = (bool) get_option( 'wpdo_term_comment_misc_bucket_enabled', '0' );
$term_misc_rows = class_exists( 'TMDO_Term_Comment_Misc_Bucket' )
? TMDO_Term_Comment_Misc_Bucket::count_rows( 'term' )
: 0;
@@ -3738,7 +3656,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
<?php
$blacklist = class_exists( 'TMDO_FSM_Automator' ) ? TMDO_FSM_Automator::blacklist() : array();
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
$all = array_merge( TMDO_Feature_Flags::HPCT_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
$all = array_merge( TMDO_Feature_Flags::ADDON_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
echo '<div class="wpdo-details-section">';
foreach ( $all as $m ) {
printf(
@@ -3818,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 );
if ( class_exists( 'TMDO_Mode_Manager' ) ) {
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();
}
$refused = self::apply_bridge_modes( self::posted_bridge_modes() );
if ( class_exists( 'TMDO_Hook_Bus_Bridge' ) ) {
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 {
@@ -3861,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
/**
* 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.
*/
@@ -3882,7 +3861,10 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
if ( ! isset( $handlers[ $section ] ) ) {
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' ) ) );
}
}
+1 -1
View File
@@ -246,7 +246,7 @@ class TMDO_Dashboard_Widget {
'%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
if ( $total_eav > 0 ) {
@@ -0,0 +1,91 @@
<?php
/**
* Admin notice for the deprecated in-house partner AddOns.
*
* The AddOns for 2meet's own plugins (bookings, collab, hub, infocards,
* mobile-bridge, playlist, quotation, spoke) only registered tables and fields
* on the partner's behalf. Partner plugins now register themselves with the
* TMDO_Anti_EAV_Aware trait (docs/PARTNER-REGISTRATION.md), so these AddOns
* are deprecated and will be removed. The SSO data needs no AddOn at all:
* the hub, spoke and SSO AddOns go without replacement (the `sso` group gave
* no measurable gain over usermeta) once its values are written back.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Warns admins who still run one of the deprecated partner AddOns.
*/
final class TMDO_Deprecated_Addons_Notice {
/** In-house plugins whose AddOn is replaced by partner self-registration. */
private const SLUGS = array( 'bookings', 'collab', 'hub', 'infocards', 'mobile-bridge', 'playlist', 'quotation', 'spoke', 'sso' );
/**
* Hook the notice.
*
* @return void
*/
public static function register(): void {
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
}
/**
* Slugs of the deprecated AddOns active on this site (or network-wide).
*
* @return string[]
*/
public static function active(): array {
$active = (array) get_option( 'active_plugins', array() );
if ( is_multisite() ) {
$active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) );
}
return array_values(
array_filter(
self::SLUGS,
static fn( string $slug ): bool => in_array( "2meet-data-optimizer-{$slug}-addon/2meet-data-optimizer-{$slug}-addon.php", $active, true )
)
);
}
/**
* Print the notice for administrators when any deprecated AddOn is active.
*
* @return void
*/
public static function render(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$active = self::active();
if ( empty( $active ) ) {
return;
}
$names = implode( ', ', array_map( static fn( string $slug ): string => "2meet-data-optimizer-{$slug}-addon", $active ) );
printf(
'<div class="notice notice-warning"><p><strong>%s</strong> %s</p><p>%s</p></div>',
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
esc_html(
sprintf(
/* translators: %s: comma-separated AddOn plugin slugs */
__( '以下 AddOn 已淘汰:%s。對應的 2meet 外掛改為自行向核心註冊資料表與欄位,AddOn 之後會移除。', '2meet-data-optimizer' ),
$names
)
),
esc_html__( '改寫方式見 2meet-data-optimizer 外掛內的 docs/PARTNER-REGISTRATION.md;partner 外掛完成自行註冊後即可停用對應 AddOn。', '2meet-data-optimizer' )
);
if ( array_intersect( $active, array( 'hub', 'spoke', 'sso' ) ) ) {
printf(
'<div class="notice notice-warning"><p>%s</p></div>',
esc_html__( 'Hub / Spoke / SSO AddOn 不需替代:2meet-hub-core 與 2meet-spoke-sso 會自行註冊資料表,SSO 值存回 usermeta 即可。停用前先執行 wp tmdo bridge-restore-native user --group=sso --retire。', '2meet-data-optimizer' )
);
}
}
}
+5 -3
View File
@@ -107,7 +107,7 @@ class TMDO_Help_Tabs {
private static function content_dashboard(): string {
return '<p>' . esc_html__( '儀表板顯示:', '2meet-data-optimizer' ) . '</p>'
. '<ul class="wpdo-list-disc">'
. '<li>' . esc_html__( 'System Overview — DB 引擎、HivePress、HPCT、Object Cache 是否啟用', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'System Overview — DB 引擎、HivePress、Object Cache 是否啟用', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'Zone 行數統計 — Hot/Warm/Cold/Archive 各自累積多少資料', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'Module 狀態 — 每個 module 在 7-state FSM 哪一格', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'Warm zone live view — 哪些 view counts / TTL 進來、24h 快過期數', '2meet-data-optimizer' ) . '</li>'
@@ -173,7 +173,6 @@ class TMDO_Help_Tabs {
. '<p>' . esc_html__( '常見原因:', '2meet-data-optimizer' ) . '</p>'
. '<ul class="wpdo-list-disc">'
. '<li>' . esc_html__( 'Hook Bus 啟用(wpdo_hook_bus_enabled = 1)+ legacy interceptors 還沒卸載', '2meet-data-optimizer' ) . '</li>'
. '<li>' . esc_html__( 'HPCT (HP Custom Tables) plugin 還沒移除 — 與 WPDO 同時攔截', '2meet-data-optimizer' ) . '</li>'
. '</ul>'
. '<p>' . esc_html__( '解法:先看 conflict-scan 詳情,必要時用 wp wpdo bridge-set off 暫停 Hook Bus 直到清理完。', '2meet-data-optimizer' ) . '</p>';
}
@@ -184,7 +183,7 @@ class TMDO_Help_Tabs {
* @return string
*/
private static function content_doctor(): string {
return '<p>' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
return '<p>' . esc_html__( '10 項自動健康檢查:', '2meet-data-optimizer' ) . '</p>'
. '<ol class="wpdo-list-disc">'
. '<li><strong>schema_drift</strong> — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>error_budget</strong> — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '</li>'
@@ -193,6 +192,9 @@ class TMDO_Help_Tabs {
. '<li><strong>postmeta_explosion</strong> — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>orphan_zone_rows</strong> — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>missing_snapshot</strong> — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>entity_group_collisions</strong> — ' . esc_html__( '同名實體群組被不同外掛以不同欄位註冊(後者被忽略)', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>orphaned_entity_groups</strong> — ' . esc_html__( '扁平表還在但已無外掛註冊的群組(已依保存的定義還原以維持可讀)', '2meet-data-optimizer' ) . '</li>'
. '<li><strong>missing_integration_addons</strong> — ' . esc_html__( 'HivePress / WooCommerce 已啟用但對應 AddOn 未啟用', '2meet-data-optimizer' ) . '</li>'
. '</ol>'
. '<p>' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '</p>';
}
@@ -0,0 +1,62 @@
<?php
/**
* Admin notice when HivePress / WooCommerce run without their AddOn.
*
* Core knows nothing about either plugin; their fields are only optimised by
* the matching AddOn. Without it, aeav_only data stays readable only through
* the orphan-group restore, and new data is not optimised at all.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Warns administrators about integrations whose AddOn is not active.
*/
final class TMDO_Missing_Addons_Notice {
/**
* Hook the notice.
*
* @return void
*/
public static function register(): void {
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
}
/**
* Print the notice for administrators when an integration lacks its AddOn.
*
* @return void
*/
public static function render(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$missing = TMDO_Compatibility::missing_integration_addons();
if ( empty( $missing ) ) {
return;
}
$lines = array();
foreach ( $missing as $plugin => $addon ) {
$lines[] = sprintf( '%s → %s', $plugin, $addon );
}
printf(
'<div class="notice notice-warning"><p><strong>%s</strong> %s</p></div>',
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
esc_html(
sprintf(
/* translators: %s: "Plugin → AddOn" pairs */
__( '以下外掛已啟用,但對應的 AddOn 沒有啟用,它們的欄位不會被優化:%s。請啟用對應的 AddOn。', '2meet-data-optimizer' ),
implode( ';', $lines )
)
)
);
}
}
@@ -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
*/
public static function register_menu_page(): void {
add_submenu_page(
$hook_suffix = add_submenu_page(
'tools.php',
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
@@ -114,6 +114,33 @@ class TMDO_Setup_Wizard {
'wpdo-setup-wizard',
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>
</form>
<?php
// Take snapshot if action requested (POST + nonce, prevents Referer leak — v3.3.x P1-6b hardening).
if ( isset( $_POST['wpdo_take_snapshot'] ) && check_admin_referer( self::NONCE_NAME ) && class_exists( 'TMDO_Snapshot_Manager' ) ) {
$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;
}
// The snapshot itself is taken in handle_snapshot_post(), before any output.
if ( isset( $_GET['snap_failed'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
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>
<!-- 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>
<div class="wpdo-bar-section">
<div class="wpdo-bar-header">
@@ -249,7 +249,7 @@ $total_commentmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comment
</div>
<!-- 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>
<div id="wpdo-cst-bench-content"></div>
</div>
+3 -3
View File
@@ -99,7 +99,7 @@ $failed = 'failed' === $state;
<!-- Run-migration panel (idle state) ──────────────────────────────── -->
<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 ) : ?>
<div class="wpdo-mw-nothing-todo">
@@ -170,7 +170,7 @@ $failed = 'failed' === $state;
<!-- Live progress panel (running state) ───────────────────────────── -->
<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>
@@ -210,7 +210,7 @@ $failed = 'failed' === $state;
<!-- Completed state ───────────────────────────────────────────────── -->
<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>
<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>
+1 -1
View File
@@ -218,7 +218,7 @@ $wpdo_format_msg = static function ( string $code ): string {
<form method="post">
<input type="hidden" name="wpdo_postmeta_cleanup" value="1">
<?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' ) ); ?>);">
<?php esc_html_e( '清理垃圾資料', '2meet-data-optimizer' ); ?>
</button>
+6 -6
View File
@@ -161,22 +161,22 @@ $post_type_options = array(
</tr>
</thead>
<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>wp_postmeta 5 rows / flat 0 → <strong>1:5(無優化)</strong></td>
<td>wp_postmeta 5 / flat 0 → 1:5(無優化)</td>
</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>wp_postmeta 5 / flat 0 → 1:5</td>
<td>wp_postmeta 5 + flat 1 → 1:5(有 flat 但 wp_postmeta 不減)</td>
</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>wp_postmeta 5 / flat 0 → 1:5</td>
<td>wp_postmeta 5 + flat 1 → 1:5(讀走 flat,寫仍雙寫)</td>
</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>wp_postmeta 5(直 SQL 繞過 Hook Bus)/ flat 0 → 1:5 ⚠️</td>
<td>wp_postmeta <strong>0</strong> / flat 1 → <strong>0:1(完全優化)</strong> ✅</td>
@@ -291,7 +291,7 @@ $post_type_options = array(
</div>
<!-- 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>
<div class="wpdo-bar-section">
<div class="wpdo-bar-header">
@@ -329,7 +329,7 @@ $post_type_options = array(
</div>
<!-- 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>
<div id="wpdo-pst-bench-content"></div>
</div>
+2 -2
View File
@@ -207,7 +207,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
</div>
<!-- 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>
<div class="wpdo-bar-section">
<div class="wpdo-bar-header">
@@ -245,7 +245,7 @@ $total_termmeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta
</div>
<!-- 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>
<div id="wpdo-tst-bench-content"></div>
</div>
+39 -29
View File
@@ -9,8 +9,8 @@
* wp wpdo member-shadow-report — Recent entity_type=user diff records.
* wp wpdo member-cutover — Switch user mode to aeav_only.
* wp wpdo member-points-check — Show balance + recent ledger for a user.
* wp wpdo member-sso-sync — Sync Hub membership claims to flat table.
* wp wpdo member-force-logout — Set token_expires_at to past → re-auth.
* wp wpdo member-sso-sync — (deprecated) Sync Hub membership claims to flat table.
* wp wpdo member-force-logout — (deprecated) Set token_expires_at to past → re-auth.
*
* @package WP_Data_Optimizer
* @since 2.5.5
@@ -35,24 +35,6 @@ if ( ! class_exists( 'WP_CLI' ) ) {
*/
class TMDO_CLI_Member {
/**
* Valid group names managed by this CLI.
*
* V2.7.0: Extended with core_profile/social/commerce/hp_user to absorb
* the legacy WP/WC/HP wp_usermeta keys.
*/
private const VALID_GROUPS = array(
'membership',
'activity',
'profile',
'sso',
'core_profile',
'social',
'commerce',
'hp_user',
'admin_prefs',
);
// ── member-audit ─────────────────────────────────────────────────────────
/**
@@ -73,7 +55,7 @@ class TMDO_CLI_Member {
WP_CLI::log( '' );
$items = array();
foreach ( self::VALID_GROUPS as $group ) {
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'user' ) as $group ) {
$table = $wpdb->prefix . 'wpdo_user_' . $group;
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
@@ -111,7 +93,7 @@ class TMDO_CLI_Member {
// ── member-backfill ───────────────────────────────────────────────────────
/**
* Backfill wp_usermeta rows into the specified user flat-table group.
* Backfill wp_usermeta rows into one user flat-table group, or all of them.
*
* Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based,
* 500 rows/batch, checkpoint stored in wpdo_migration_status.
@@ -119,7 +101,10 @@ class TMDO_CLI_Member {
* ## OPTIONS
*
* [--group=<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>]
* : Rows per batch (default 500, max 2000).
@@ -134,21 +119,38 @@ class TMDO_CLI_Member {
*
* wp wpdo member-backfill --group=membership --dry-run
* wp wpdo member-backfill --group=membership
* wp wpdo member-backfill --group=sso --reset
* wp wpdo member-backfill --all
*
* @param array $args Positional arguments.
* @param array $assoc_args Named arguments.
*/
public function member_backfill( $args, $assoc_args ): void {
$group = (string) ( $assoc_args['group'] ?? 'membership' );
$batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) );
$dry_run = isset( $assoc_args['dry-run'] );
$reset = isset( $assoc_args['reset'] );
$valid = TMDO_Entity_Migration_Engine::backfillable_groups( 'user' );
if ( ! in_array( $group, self::VALID_GROUPS, true ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) );
if ( isset( $assoc_args['all'] ) ) {
if ( isset( $assoc_args['group'] ) ) {
WP_CLI::error( 'Use either --group or --all, not both.' );
}
$groups = $valid;
} else {
$groups = array( (string) ( $assoc_args['group'] ?? 'membership' ) );
if ( ! in_array( $groups[0], $valid, true ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid ) );
}
}
foreach ( $groups as $group ) {
$this->backfill_group( $group, $batch_size, $dry_run, $reset );
}
}
/**
* Backfill one user group and report the result.
*/
private function backfill_group( string $group, int $batch_size, bool $dry_run, bool $reset ): void {
if ( $reset ) {
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
WP_CLI::log( "Checkpoint cleared for user/{$group}." );
@@ -298,8 +300,8 @@ class TMDO_CLI_Member {
$result = TMDO_Mode_Manager::set( 'user', 'aeav_only' );
if ( isset( $result['error'] ) ) {
WP_CLI::error( 'Cutover failed: ' . $result['error'] );
if ( is_wp_error( $result ) ) {
WP_CLI::error( 'Cutover failed: ' . $result->get_error_message() );
}
WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' );
@@ -356,6 +358,9 @@ class TMDO_CLI_Member {
/**
* Sync Hub membership claims into the user flat table.
*
* Deprecated since 1.2.0: the user `sso` group is no longer registered, so
* its flat table is never created on new installs. Will be removed.
*
* Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token
* from usermeta and writes them into the sso group flat table.
* Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing
@@ -381,6 +386,7 @@ class TMDO_CLI_Member {
* @param array $assoc_args Named arguments.
*/
public function member_sso_sync( $args, $assoc_args ): void {
WP_CLI::warning( 'member-sso-sync is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' );
global $wpdb;
$user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0;
@@ -466,6 +472,9 @@ class TMDO_CLI_Member {
/**
* Force re-authentication by setting token_expires_at to a past datetime.
*
* Deprecated since 1.2.0: works on the user `sso` flat table, which is no
* longer registered. Will be removed.
*
* The Spoke silent-refresh logic detects an expired token_expires_at and
* redirects the user to re-authenticate against the Hub. If the Hub has
* the account blocked, the user is logged out from all Spokes.
@@ -490,6 +499,7 @@ class TMDO_CLI_Member {
* @param array $assoc_args Named arguments.
*/
public function member_force_logout( $args, $assoc_args ): void {
WP_CLI::warning( 'member-force-logout is deprecated since 1.2.0 (the user sso group is no longer registered) and will be removed.' );
if ( ! isset( $assoc_args['confirm'] ) ) {
WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' );
}
+147 -32
View File
@@ -198,29 +198,20 @@ class TMDO_CLI_Post {
/**
* Backfill one post entity group from wp_postmeta into its flat table
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE).
* via a single bulk SQL pivot (ON DUPLICATE KEY UPDATE). Groups with
* JSON-typed fields are backfilled row by row instead.
*
* Idempotent — safe to re-run. Skips JSON-typed fields (handled by the
* row-by-row backfill phase in v2.9.4+).
* Idempotent — safe to re-run.
*
* ## OPTIONS
*
* --group=<group>
* : Entity group name. Required.
* ---
* options:
* - wp_core
* - attachment
* - wc_product
* - hp_listing_core
* - hp_request_core
* - hp_vendor_core
* - nav_menu_item
* ---
* : Registered post entity group name (e.g. wp_core, attachment,
* hp_listing_core). Required.
*
* ## EXAMPLES
*
* wp wpdo post-migrate-group --group=wc_product
* wp wpdo post-migrate-group --group=hp_listing_core
* wp wpdo post-migrate-group --group=hp_listing_core
*
* @param array $args Positional arguments (unused).
@@ -259,6 +250,94 @@ class TMDO_CLI_Post {
);
}
// ── post-reconcile-groups ─────────────────────────────────────────────────
/**
* Move post values written under legacy routing into their post type's
* group table, then switch on post_type-scoped routing.
*
* Needed once on sites that already hold post flat rows (the installer
* leaves tmdo_post_scoped_routing off there). Resumable: an interrupted
* run continues from its cursor.
*
* ## OPTIONS
*
* [--dry-run]
* : Count the values that would be copied / overwritten; write nothing.
*
* [--batch=<n>]
* : Rows per batch. Default 500.
*
* ## EXAMPLES
*
* wp tmdo post-reconcile-groups --dry-run
* wp tmdo post-reconcile-groups
*
* @param array<int, string> $args Positional arguments (unused).
* @param array<string, string> $assoc_args Named arguments.
*/
public function post_reconcile_groups( $args, $assoc_args ): void {
$dry_run = isset( $assoc_args['dry-run'] );
$report = TMDO_Post_Group_Reconciler::run( $dry_run, (int) ( $assoc_args['batch'] ?? 500 ) );
if ( empty( $report['moves'] ) ) {
WP_CLI::log( 'No shared post keys to move.' );
}
foreach ( $report['moves'] as $m ) {
WP_CLI::log( sprintf( '%s → %s %s: %d value(s)%s', $m['source'], $m['target'], $m['key'], $m['copied'], $dry_run ? sprintf( ', %d would overwrite a different value', $m['overwritten'] ) : '' ) );
}
if ( $dry_run ) {
WP_CLI::success( '[dry-run] Nothing written. Re-run without --dry-run to copy and switch on scoped routing.' );
return;
}
WP_CLI::success( 'Post groups reconciled; tmdo_post_scoped_routing is on.' );
}
// ── post-status-sync ──────────────────────────────────────────────────────
/**
* Add post_status / post_date copies (and their index) to every post
* group table and fill them from wp_posts.
*
* Existing tables only get the columns here, never in a web request:
* ALTER on a large table can take seconds. Afterwards status changes keep
* the copies current. Re-run after bulk writes that bypass WordPress
* (e.g. post-reconcile-groups, post migrations).
*
* ## OPTIONS
*
* [--dry-run]
* : Count rows whose copies are missing or stale; change nothing.
*
* [--batch=<n>]
* : Rows per UPDATE. Default 1000.
*
* ## EXAMPLES
*
* wp tmdo post-status-sync --dry-run
* wp tmdo post-status-sync
*
* @param array<int, string> $args Positional arguments (unused).
* @param array<string, string> $assoc_args Named arguments.
*/
public function post_status_sync( $args, $assoc_args ): void {
$dry_run = isset( $assoc_args['dry-run'] );
$report = TMDO_Post_Status_Sync::run( $dry_run, (int) ( $assoc_args['batch'] ?? 1000 ) );
if ( empty( $report ) ) {
WP_CLI::log( 'No post group tables.' );
}
foreach ( $report as $group => $r ) {
WP_CLI::log( sprintf( '%s: %d row(s), %d missing or stale%s', $group, $r['rows'], $r['mismatched'], $dry_run ? '' : sprintf( ' → %s', TMDO_Post_Status_Sync::state( $group ) ) ) );
}
if ( $dry_run ) {
WP_CLI::success( '[dry-run] Nothing changed.' );
return;
}
WP_CLI::success( 'post_status / post_date copies are in sync.' );
}
// ── post-cleanup (v2.9.3) ─────────────────────────────────────────────────
/**
@@ -351,6 +430,10 @@ class TMDO_CLI_Post {
* Safe to run with the filter enabled; the filter prevents new postmeta
* writes from re-bloating the table.
*
* With --options it removes instead the rows the filter wrote to wp_options
* (`_transient_wpdo_hp_pm_*`) before 1.2.0 / AddOn 0.3.0 kept them in the
* object cache; works without the AddOn active.
*
* ## OPTIONS
*
* [--dry-run]
@@ -359,10 +442,17 @@ class TMDO_CLI_Post {
* [--confirm]
* : Required to actually DELETE rows.
*
* [--options]
* : Clean the AddOn's rows in wp_options instead of wp_postmeta.
*
* [--batch=<n>]
* : Rows per DELETE with --options. Default 5000.
*
* ## EXAMPLES
*
* wp wpdo cleanup-hp-transients --dry-run
* wp wpdo cleanup-hp-transients --confirm
* wp wpdo cleanup-hp-transients --options --dry-run
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
@@ -382,6 +472,11 @@ class TMDO_CLI_Post {
);
}
if ( isset( $assoc_args['options'] ) ) {
self::cleanup_hp_transient_options( $dry_run, max( 1, (int) ( $assoc_args['batch'] ?? 5000 ) ) );
return;
}
if ( ! class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
WP_CLI::error( 'TMDO_Hivepress_Transient_Filter not loaded.' );
}
@@ -415,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) ──────────────────────────────────────────
/**
@@ -816,23 +944,7 @@ class TMDO_CLI_Post {
* @return string|null
*/
private static function group_post_type_for_cli( string $group ): ?string {
switch ( $group ) {
case 'attachment':
return 'attachment';
case 'wc_product':
return 'product';
case 'hp_listing_core':
return 'hp_listing';
case 'hp_request_core':
return 'hp_request';
case 'hp_vendor_core':
return 'hp_vendor';
case 'nav_menu_item':
return 'nav_menu_item';
case 'wp_core':
default:
return null;
}
return TMDO_Post_Fields::post_type_for_group( $group );
}
}
@@ -853,3 +965,6 @@ WP_CLI::add_command( 'wpdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchm
WP_CLI::add_command( 'tmdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) );
WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
WP_CLI::add_command( 'tmdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) );
WP_CLI::add_command( 'wpdo post-reconcile-groups', array( new TMDO_CLI_Post(), 'post_reconcile_groups' ) );
WP_CLI::add_command( 'tmdo post-reconcile-groups', array( new TMDO_CLI_Post(), 'post_reconcile_groups' ) );
WP_CLI::add_command( 'tmdo post-status-sync', array( new TMDO_CLI_Post(), 'post_status_sync' ) );
+32 -13
View File
@@ -429,7 +429,7 @@ class TMDO_CLI_Term_Comment {
WP_CLI::log( sprintf( 'Mode: %s', $term_mode ) );
WP_CLI::log( sprintf( 'wp_terms: %s', number_format_i18n( $terms_count ) ) );
WP_CLI::log( sprintf( 'wp_termmeta: %s (1:%s)', number_format_i18n( $termmeta_count ), $terms_count > 0 ? round( $termmeta_count / $terms_count, 2 ) : 'n/a' ) );
self::print_flat_row( $wpdb->prefix . 'wpdo_term_hp_taxonomy', 'wpdo_term_hp_taxonomy' );
self::print_group_rows( 'term' );
self::print_flat_row( $wpdb->prefix . 'wpdo_term_misc', 'wpdo_term_misc' );
WP_CLI::log( '' );
@@ -437,14 +437,14 @@ class TMDO_CLI_Term_Comment {
WP_CLI::log( sprintf( 'Mode: %s', $comment_mode ) );
WP_CLI::log( sprintf( 'wp_comments: %s', number_format_i18n( $comments_count ) ) );
WP_CLI::log( sprintf( 'wp_commentmeta: %s (1:%s)', number_format_i18n( $commentmeta_count ), $comments_count > 0 ? round( $commentmeta_count / $comments_count, 2 ) : 'n/a' ) );
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_hp_review', 'wpdo_comment_hp_review' );
self::print_group_rows( 'comment' );
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_misc', 'wpdo_comment_misc' );
WP_CLI::log( '' );
WP_CLI::log( '── Filter chain status ──' );
self::print_filter_status( 'wpdo_term_comment_garbage_filter_enabled', 'Garbage filter (Phase 1)' );
self::print_filter_status( 'wpdo_wc_term_count_filter_enabled', 'WC term count filter (Phase 3)' );
self::print_filter_status( 'wpdo_term_comment_misc_bucket_enabled', 'Misc bucket (Phase 4)' );
self::print_filter_status( 'TMDO_Term_Comment_Garbage_Filter', 'Garbage filter (Phase 1)' );
self::print_filter_status( 'TMDO_WC_Term_Count_Filter', 'WC term count filter (Phase 3)' );
self::print_filter_status( 'TMDO_Term_Comment_Misc_Bucket', 'Misc bucket (Phase 4)' );
WP_CLI::log( '' );
WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) );
@@ -470,6 +470,21 @@ class TMDO_CLI_Term_Comment {
return 'Both entities at disabled. Promote to dual_write first to enable Hook Bus interception.';
}
/**
* Print the row count of every registered group table of an entity.
*
* @param string $entity_type 'term' or 'comment'.
* @return void
*/
private static function print_group_rows( string $entity_type ): void {
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $type ) {
if ( $type === $entity_type ) {
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
self::print_flat_row( $table, TMDO_TABLE_PREFIX . $entity_type . '_' . $group );
}
}
}
/**
* Print a flat table's row count (or "(missing)" when not present).
*
@@ -489,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.
* @return void
*/
private static function print_filter_status( string $option_key, string $label ): void {
$enabled = (bool) get_option( $option_key, '1' );
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $enabled ? '✓ enabled' : '✗ disabled' ) );
private static function print_filter_status( string $filter_class, string $label ): void {
if ( ! class_exists( $filter_class ) ) {
WP_CLI::log( sprintf( '%-32s%s', $label . ':', '— not installed' ) );
return;
}
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $filter_class::is_enabled() ? '✓ enabled' : '✗ disabled' ) );
}
// ── term-comment-shadow-report (v2.12.5 Phase 5) ──────────────────────────
@@ -647,7 +666,8 @@ class TMDO_CLI_Term_Comment {
);
}
$valid_groups = array( 'all', 'hp_taxonomy', 'hp_review' );
$registered = TMDO_Term_Comment_Backfill::registered_groups();
$valid_groups = array_merge( array( 'all' ), array_keys( $registered ) );
if ( ! in_array( $group, $valid_groups, true ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid_groups ) );
}
@@ -656,8 +676,7 @@ class TMDO_CLI_Term_Comment {
if ( 'all' === $group ) {
$results = TMDO_Term_Comment_Backfill::backfill_all( $dry_run );
} else {
$entity_type = 'hp_review' === $group ? 'comment' : 'term';
$results = array( $group => TMDO_Term_Comment_Backfill::backfill_group( $entity_type, $group, $dry_run ) );
$results = array( $group => TMDO_Term_Comment_Backfill::backfill_group( $registered[ $group ], $group, $dry_run ) );
}
// Render table.
+278 -4
View File
@@ -91,13 +91,15 @@ class TMDO_CLI_V2 {
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $flat_table )
);
$flat_count = $flat_exists
// Flat rows holding at least one value (wp_core keeps an all-NULL row per post).
$managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
$has_value = implode( ' OR ', array_map( static fn( $k ) => '`' . TMDO_Schema_Manager::sanitize_column_name( $k ) . '` IS NOT NULL', $managed_keys ) );
$flat_count = $flat_exists && '' !== $has_value
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" )
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}` WHERE {$has_value}" )
: 0;
// EAV source: count distinct entity IDs that have at least one managed key.
$managed_keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group_name );
$eav_table = $eav_tables[ $entity_type ] ?? '';
$eav_count = 0;
@@ -106,7 +108,12 @@ class TMDO_CLI_V2 {
$placeholders = implode( ', ', array_fill( 0, count( $managed_keys ), '%s' ) );
// $id_col comes from a hardcoded map; $eav_table is $wpdb->*meta (framework-managed); $placeholders is %s repeats only.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$sql = "SELECT COUNT(DISTINCT `{$id_col}`) FROM `{$eav_table}` WHERE `meta_key` IN ({$placeholders})";
$sql = "SELECT COUNT(DISTINCT m.`{$id_col}`) FROM `{$eav_table}` m WHERE m.`meta_key` IN ({$placeholders})";
// A group of some post types only: other posts holding its keys are not its rows.
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( 'post', $group_name ) : array();
if ( $post_types ) {
$sql .= " AND m.post_id IN ( SELECT ID FROM {$wpdb->posts} WHERE post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "') )";
}
$eav_count = (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$managed_keys ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
@@ -152,6 +159,265 @@ class TMDO_CLI_V2 {
WP_CLI::success( "Hook Bus: {$state}" );
}
/**
* Set an entity's bridge mode. Goes through TMDO_Mode_Manager::set(): unsafe
* jumps and promotions refused by the Promotion Guard (aeav_only on
* HivePress / WooCommerce sites) fail and leave the mode unchanged.
*
* ## OPTIONS
*
* <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
* aeav_only. Resumable; re-run after an interruption.
*
* ## OPTIONS
*
* <entity>
* : post | user | term | comment
*
* [--dry-run]
* : Count the flat rows per group and how their values compare with native
* meta (missing / differs / same / default skipped); write nothing.
*
* [--overwrite]
* : Flat values replace differing native values. Default in aeav_only.
*
* [--only-missing]
* : Only fill native rows that do not exist; never replace. Default in every
* other mode, where native meta is the source of truth.
*
* [--batch=<n>]
* : Rows per batch. Default 500.
*
* [--demote]
* : After a complete run, switch the entity to dual_write (both copies kept in step).
*
* [--group=<group>]
* : Only this group.
*
* [--retire]
* : With --group, after the write-back: forget the group (registry_meta row)
* and rename its flat table to <table>_retired_<Ymd>. Refused while a plugin
* still registers the group or native meta differs from the flat table.
*
* ## EXAMPLES
*
* wp tmdo bridge-restore-native user --dry-run
* wp tmdo bridge-restore-native user --demote
* wp tmdo bridge-restore-native user --group=sso --retire
*
* @param array<int, string> $args Positional arguments.
* @param array<string, string> $assoc_args Named arguments.
*/
public function bridge_restore_native( $args, $assoc_args ): void {
$entity = (string) ( $args[0] ?? '' );
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--overwrite|--only-missing] [--batch=<n>] [--demote] [--group=<group> [--retire]]' );
}
$dry_run = isset( $assoc_args['dry-run'] );
$group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) );
if ( isset( $assoc_args['retire'] ) && '' === $group ) {
WP_CLI::error( '--retire needs --group=<group>.' );
}
if ( isset( $assoc_args['overwrite'] ) && isset( $assoc_args['only-missing'] ) ) {
WP_CLI::error( 'Use either --overwrite or --only-missing, not both.' );
}
$policy = isset( $assoc_args['overwrite'] ) ? 'overwrite' : ( isset( $assoc_args['only-missing'] ) ? 'only_missing' : '' );
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group, $policy );
WP_CLI::log( "Policy: {$report['policy']}" );
foreach ( $report['groups'] as $group => $g ) {
WP_CLI::log(
sprintf(
'%s/%s: %d row(s) — native missing %d, differs %d, same %d, default skipped %d%s',
$entity,
$group,
$g['rows'],
$g['missing'],
$g['differs'],
$g['same'],
$g['default_skipped'],
$dry_run ? '' : sprintf( '; %d native value(s) written', $g['written'] )
)
);
}
if ( $dry_run ) {
WP_CLI::success( '[dry-run] Nothing written.' );
return;
}
if ( isset( $assoc_args['retire'] ) ) {
$retired = TMDO_Native_Restorer::retire( $entity, $group );
if ( 'retired' !== $retired['status'] ) {
WP_CLI::error( sprintf( 'Not retired (%s%s). Written values stay in native meta.', $retired['status'], $retired['mismatches'] ? ", {$retired['mismatches']} value(s) differ" : '' ) );
}
WP_CLI::success( "{$entity}/{$group} retired; flat table kept as {$retired['archived_table']}." );
return;
}
WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' );
}
/**
* Find flat text / textarea values stored as the literal "Array" (pre-1.1.0
* type caster) and rewrite them from native meta.
*
* Native rows that are "Array" too cannot be repaired here; they are listed
* so they can be restored from a backup.
*
* ## OPTIONS
*
* [--entity=<entity>]
* : post | user | term | comment. Default: all four.
*
* [--confirm]
* : Write the repairs. Without it the command only reports.
*
* ## EXAMPLES
*
* wp tmdo repair-array-literals
* wp tmdo repair-array-literals --entity=user --confirm
*
* @param array<int, string> $args Positional arguments (unused).
* @param array<string, string> $assoc_args Named arguments.
*/
public function repair_array_literals( $args, $assoc_args ): void {
$entities = isset( $assoc_args['entity'] ) ? array( (string) $assoc_args['entity'] ) : TMDO_Entity_Registry::VALID_ENTITY_TYPES;
$apply = isset( $assoc_args['confirm'] );
foreach ( $entities as $entity ) {
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
WP_CLI::error( "Unknown entity: {$entity}" );
}
$report = TMDO_Array_Literal_Repair::run( $entity, $apply );
WP_CLI::log( sprintf( '%s: %d "Array" value(s) in flat tables, %d repairable from native, %d repaired.', $entity, $report['found'], $report['repairable'], $report['repaired'] ) );
foreach ( $report['unrepairable'] as $row ) {
WP_CLI::log( sprintf( ' unrepairable %s/%s #%d (%s)', $row['group'], $row['key'], $row['id'], $row['reason'] ) );
}
foreach ( $report['native_polluted'] as $row ) {
WP_CLI::warning( sprintf( 'native meta is "Array": %s %s #%d — restore it from a backup', $entity, $row['key'], $row['id'] ) );
}
}
WP_CLI::success( $apply ? 'Repair finished.' : '[dry-run] Nothing written. Re-run with --confirm to repair.' );
}
/**
* Audit per-module state + shadow_read flags.
*
@@ -784,6 +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( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'wpdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
WP_CLI::add_command( 'tmdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) );
WP_CLI::add_command( 'wpdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
WP_CLI::add_command( 'tmdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) );
WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
WP_CLI::add_command( 'tmdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
WP_CLI::add_command( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) );
+128 -88
View File
@@ -42,7 +42,6 @@ class TMDO_CLI {
$compat = TMDO_Compatibility::check();
WP_CLI::log( 'HivePress: ' . ( $compat['hivepress'] ? 'Active' : 'Not found' ) );
WP_CLI::log( 'HP Custom Tables: ' . self::format_hpct_status( $compat ) );
WP_CLI::log( 'Object Cache: ' . ( wp_using_ext_object_cache() ? 'External' : 'Built-in' ) );
WP_CLI::log( '' );
@@ -57,10 +56,10 @@ class TMDO_CLI {
WP_CLI::log( 'Module States:' );
$hpct_flags = TMDO_Feature_Flags::hpct_modules();
if ( ! empty( $hpct_flags ) ) {
WP_CLI::log( ' [HPCT Modules]' );
foreach ( $hpct_flags as $module => $state ) {
$addon_flags = TMDO_Feature_Flags::addon_modules();
if ( ! empty( $addon_flags ) ) {
WP_CLI::log( ' [AddOn Modules]' );
foreach ( $addon_flags as $module => $state ) {
WP_CLI::log( " {$module}: {$state}" );
}
}
@@ -95,6 +94,92 @@ class TMDO_CLI {
WP_CLI::success( 'Tables installed. DB version: ' . get_option( 'wpdo_db_version' ) );
}
/**
* Report the flat table of every backfillable group of every entity type,
* plus the installer's migration status and points ledger tables.
*
* @return bool Every table exists.
*/
public static function check_entity_tables(): bool {
global $wpdb;
$tables = array( $wpdb->prefix . 'wpdo_migration_status', $wpdb->prefix . 'wpdo_user_points_ledger' );
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
foreach ( TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type ) as $group ) {
$tables[] = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
}
}
$ok = true;
foreach ( $tables as $t ) {
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
} else {
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
}
if ( $exists ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
} else {
WP_CLI::warning( " [MISSING] {$t} — run `wp wpdo install` to create." );
$ok = false;
}
}
return $ok;
}
/**
* Report Promotion Guard violations and the recorded drift status of every
* entity that writes flat tables or has been checked (informational).
*/
public static function report_guard_and_drift(): void {
WP_CLI::log( '' );
$violations = TMDO_Promotion_Guard::violations();
if ( empty( $violations ) ) {
WP_CLI::log( ' [OK] Promotion Guard: no violation' );
}
foreach ( $violations as $entity_type => $integrations ) {
WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." );
}
$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.
*
@@ -196,30 +281,12 @@ class TMDO_CLI {
WP_CLI::log( $exists ? " [OK] {$t}" : " [NOT CREATED] {$t} (will be created on first use)" );
}
// Check user entity flat tables (v2.5.5+).
$user_entity_tables = array(
$wpdb->prefix . 'wpdo_user_membership',
$wpdb->prefix . 'wpdo_user_activity',
$wpdb->prefix . 'wpdo_user_profile',
$wpdb->prefix . 'wpdo_user_sso',
$wpdb->prefix . 'wpdo_user_points_ledger',
$wpdb->prefix . 'wpdo_migration_status',
);
foreach ( $user_entity_tables as $t ) {
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $t ) );
} else {
$exists = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $t ) );
}
if ( $exists ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$t}`" );
WP_CLI::log( " [OK] {$t} ({$rows} rows)" );
} else {
WP_CLI::warning( " [MISSING] {$t} — run \`wp wpdo install\` to create." );
// Entity flat tables, from the registry (was a hard-coded user list that
// named the never-registered sso group).
if ( ! self::check_entity_tables() ) {
$all_ok = false;
}
}
self::report_guard_and_drift();
// Check partner plugin custom tables via Custom Table Registry (v2.6.0+).
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
@@ -330,6 +397,40 @@ class TMDO_CLI {
WP_CLI::log( ' [OK] Crypto key derivable (AUTH_KEY / SECURE_AUTH_SALT present)' );
}
// ── Entity group name collisions ─────────────────────────────────────────
$collisions = TMDO_Entity_Registry::get_collisions();
if ( empty( $collisions ) ) {
WP_CLI::log( ' [OK] No entity group name collisions' );
}
foreach ( $collisions as $c ) {
WP_CLI::warning(
sprintf(
' [COLLISION] %s/%s registered twice with different fields (second ignored) — added: %s; removed: %s; changed: %s',
$c['entity_type'],
$c['group'],
implode( ', ', $c['added'] ) ?: '-',
implode( ', ', $c['removed'] ) ?: '-',
implode( ', ', $c['changed'] ) ?: '-'
)
);
$all_ok = false;
}
// ── Orphaned entity groups (restored from wpdo_registry_meta) ───────────
foreach ( TMDO_Entity_Registry::get_restored_groups() as $r ) {
WP_CLI::warning(
sprintf(
' [ORPHAN] %s/%s — no active plugin registers it; restored %d field(s) from wpdo_registry_meta%s%s. Reactivate the owner or write the data back before removing it.',
$r['entity_type'],
$r['group'],
$r['fields'],
$r['skipped'] ? ' (skipped, owned by another group: ' . implode( ', ', $r['skipped'] ) . ')' : '',
$r['native'] ? ' (left on native meta, which has newer data: ' . implode( ', ', $r['native'] ) . ')' : ''
)
);
$all_ok = false;
}
// Check recent errors.
$errors = TMDO_Logger::get_recent( '', 5 );
if ( ! empty( $errors ) ) {
@@ -634,51 +735,6 @@ class TMDO_CLI {
WP_CLI::success( "Module {$module} disabled (idle)." );
}
/**
* Import settings from HP Custom Tables.
*
* ## EXAMPLES
*
* wp wpdo import-hpct
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
* @return void
*
* @subcommand import-hpct
*/
public function import_hpct( $args, $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
// HPCT is a HivePress-family plugin; the importer ships in that AddOn.
if ( ! class_exists( 'TMDO_HPCT_Import' ) ) {
WP_CLI::error( 'import-hpct needs 2meet-data-optimizer-hivepress-addon to be active.' );
}
if ( TMDO_HPCT_Import::is_imported() ) {
WP_CLI::warning( 'HPCT settings have already been imported.' );
return;
}
if ( ! TMDO_HPCT_Import::can_import() ) {
WP_CLI::error( 'HP Custom Tables plugin not detected or already imported.' );
}
// Show preview.
$preview_data = TMDO_HPCT_Import::preview();
if ( ! empty( $preview_data['modules'] ) ) {
WP_CLI::log( 'Import preview:' );
WP_CLI\Utils\format_items( 'table', $preview_data['modules'], array( 'module', 'hpct_status', 'wpdo_state' ) );
}
WP_CLI::confirm( 'Proceed with import?' );
$result = TMDO_HPCT_Import::run();
if ( is_wp_error( $result ) ) {
WP_CLI::error( $result->get_error_message() );
}
WP_CLI::success( 'HPCT settings imported successfully. You can now deactivate HP Custom Tables.' );
}
/**
* Run performance benchmark.
*
@@ -1396,22 +1452,6 @@ class TMDO_CLI {
}
}
/**
* Format the HPCT status string for display.
*
* @param array $compat Compatibility check result array.
* @return string Human-readable HPCT status.
*/
private static function format_hpct_status( array $compat ): string {
if ( $compat['hpct_active'] && $compat['hpct_imported'] ) {
return 'Active (Imported)';
}
if ( $compat['hpct_active'] ) {
return 'Active (Not imported — run `wp wpdo import-hpct`)';
}
return 'Not found';
}
/**
* Create the appropriate migration instance for a module name.
*
+1 -1
View File
@@ -36,7 +36,7 @@
"scripts": {
"test": "phpunit --configuration phpunit.xml",
"test:integration": "phpunit --configuration phpunit-integration.xml",
"phpcs": "phpcs --standard=WordPress includes/ admin/ cli/ modules/ 2meet-data-optimizer.php uninstall.php",
"phpcs": "phpcs --standard=phpcs.xml",
"stan": "phpstan analyse --no-progress",
"stan:baseline": "phpstan analyse --no-progress --generate-baseline=phpstan-baseline.neon"
},
+109
View File
@@ -0,0 +1,109 @@
# 規模效能基準:原生 postmeta vs Hot 表 vs Entity 表
**量測日期**:2026-09-25 **分支**:`hardening/2.0` **目標規模**:vendor 100,000、listing 1,000,000
## 如何重現
```bash
# DB 帳密沿用整合測試的 TMDO_TEST_DB_* 環境變數;資料庫名稱必須以 _bench 結尾
TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/seed.php --vendors=100000 --listings=1000000 # 約 7.5 分鐘
TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/run.php --iterations=20 # 約 20 分鐘
```
## 環境與資料
| 項目 | 值 |
|---|---|
| 資料庫 | MariaDB 10.11.14(本機,InnoDB 預設設定) |
| 主機 | 8 核、31 GB RAM、NVMe |
| 資料 | 100,000 vendor(users + hp_vendor posts)、1,000,000 listing(90% publish) |
| 原生 meta | postmeta 9,549,796 筆(951 MB)、usermeta 600,000 筆 |
| Entity 表 | `wpdo_post_hp_listing_core` 1,000,000 筆(379 MB) |
| Hot 表 | `wpdo_hot_hp_listing` 1,000,000 筆(229 MB) |
SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個條件一個 INNER JOIN、數值用 CAST、`SQL_CALC_FOUND_ROWS`),Hot 是 `TMDO_Query_Router`,Entity 是 `TMDO_Post_Query_Router`。每個情境 20 次,另有 3 次暖機不計入,參數隨機但可重現(`--seed=7`)。寫入在交易內執行後 rollback。
> 20 次取樣下 p95 等於最大值,僅供參考。
## 結果
| 情境 | 形式 | p50 ms | p95 ms | 結果筆數 | 使用索引 |
|---|---|---:|---:|---:|---|
| 1. 價格區間搜尋(命中約 20%) | 原生 | **14,695** | 14,890 | 180,292 | postmeta:post_id |
| | Hot | 558 | 3,605 | 180,329 | h:ALL |
| | Entity | 652 | 3,817 | 180,300 | e:ALL |
| 2. 價格區間 + verified=1 | 原生 | **17,094** | 17,250 | 54,197 | postmeta:post_id ×2 |
| | Hot | 277 | 299 | 54,109 | `idx_hp_verified_hp_price`(複合) |
| | Entity | 337 | 340 | 54,180 | e:ALL |
| 3. 某 vendor 名下的 listing | `post_parent`(HivePress 實際做法) | 0.19 | 0.25 | 7 | posts:post_parent |
| | 原生 meta `hp_vendor` | **14,432** | 14,599 | 8 | postmeta:post_id |
| | Hot | **3,405** | 3,651 | 10 | 無可用索引 |
| | Entity | 0.28 | 0.33 | 8 | `idx_hp_vendor` |
| 4. 單筆 listing 讀全部 meta | 原生 | 0.12 | 0.15 | 9 列 | postmeta:post_id |
| | Hot / Entity | 0.09 | 0.11–0.14 | 1 列 | 主鍵 |
| 5. 列表頁預載 20 筆 meta | 原生 | 0.59 | 0.61 | 181 列 | postmeta:post_id |
| | Hot / Entity | 0.21–0.22 | 0.23–0.26 | 20 列 | 主鍵 |
| 6. 單一 vendor 讀使用者 meta | 原生 | 0.11 | 0.14 | 6 列 | usermeta:user_id |
| | Entity | 0.08 | 0.11 | 1 列 | 主鍵 |
| 7. 寫入:更新一筆價格 | 原生 | 0.12 | 0.16 | — | — |
| | Entity | 0.10 | 0.13 | — | — |
| | dual_write(兩邊都寫) | 0.22 | 0.26 | — | — |
## 結論
1. **原生 meta_query 在百萬規模下無法使用。** 任何以 meta 為條件的搜尋都要 14–17 秒,扁平化是必要的,不是錦上添花。
2. **扁平表的價值集中在「搜尋與篩選」。** 單筆或批次讀 meta(情境 4–6)三種形式都在 1 ms 內,差距可以忽略,而且實務上還有 object cache。
3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。
- **PR-028**:post 群組表可存 `post_status`、`post_date` 副本與 `(post_status, post_date)` 索引(`wp tmdo post-status-sync`)。1M 列實測:加欄位 0.05 秒(instant)、建索引 5.3 秒、分批(1,000 列)回填 32 秒,之後與 `posts` 0 筆不一致。欄位 collation 必須跟 `posts.post_status` 相同,否則比對會出現 illegal mix of collations。查詢改寫在 PR-029。
- **PR-029**:副本 ready 且無 NULL 時,單一 post type、明確狀態、依日期排序的搜尋改為 INNER JOIN、`post_status IN (…)`、`ORDER BY` 副本 `post_date`、不 GROUP BY(bench 變體 `entity+copy`,`--iterations=5`)。仍帶 `SQL_CALC_FOUND_ROWS` 時:情境 1 p50 670→770 ms、**p95 3,739→773 ms**;情境 2 p50 356→448 ms(變慢,因為算總數仍要掃完所有符合列)。拿掉 `SQL_CALC_FOUND_ROWS` 後同形狀情境 1 只要 **8 ms**(走 `idx_post_status_date`、LIMIT 提早結束)→ 完整效益要搭配 PR-030。
- **PR-030**:路由後的搜尋不再帶 `SQL_CALC_FOUND_ROWS`;總數改由同一查詢的 `COUNT(*)` 回答,存在物件快取,key 綁 `last_changed(posts)`(核心存文、Hook Bus 扁平寫入都會推進)。1M 實測:快取命中時整頁 **8 ms**(原 694 / 363 ms);快取失效後第一次為頁面 8 ms + COUNT 608 / 344 ms,與原本相當。任何 post 寫入都讓全部計數失效(WP last_changed 語意),寫入頻繁的站命中率較低。
4. **Hot 表的索引規則有漏洞。** `add_covering_indexes()` 只替 decimal、tinyint,以及名稱以 `_time` 結尾的 bigint 建索引,所以 `hp_vendor` 沒有索引,情境 3 要 3.4 秒,比 Entity 慢約 12,000 倍。
5. **Entity 表只有單欄索引。** 情境 2 輸給有複合索引的 Hot 表(337 ms 對 277 ms)。
- **PR-027 之後**:群組可用 `register_group( ..., array( 'indexes' => ... ) )` 宣告複合索引。在 1M 列的 Entity 表加上 `cidx_verified_price (hp_verified, hp_price)` 花 8.1 秒;重跑情境 2(`--iterations=10`),Entity p50 從 337 ms 降到 **278 ms**(p95 285 ms),Hot 是 269 ms(p95 280 ms),差距在量測誤差內。執行計畫改走 `e:cidx_verified_price`。
6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
## 前台與寫入鏈實測(dev36,Release 2.1,2026-09-26)
上面的數字是純 SQL。這一節是真實站台(dev36:HivePress 1.7.31 + Marketplace、WooCommerce 11.1.2、Redis object cache、listing 約 10 萬筆)上的整頁與整條寫入鏈,決策背景見 `adr-004-search-index-mode-for-hp-wc.md`。
**前台搜尋**(`tests/live/tools/fe.sh`:每個 URL 5 次取中位數,`X-S36-Ms` = 整個請求到 `template_redirect`)。「routed」是 post 模式 shadow_read + 核心 2.1 router + HivePress AddOn 0.2.0;「1.0.x」是整合測試當時的 router(報告 `notes/tmdo-integration-test-dev36-2026-09-26.md`)。
| URL | 原生 ms | 1.0.x ms | 2.1 routed ms | ids 與原生相同 |
|---|---:|---:|---:|---|
| listing 價格區間 | 614 | 496 | **323** | 是 |
| listing 價格區間 + `_sort=price__asc` | 975 | 1,062 | **435** | 是 |
| listing 時薪區間 + `_sort=hourly_rate__desc` | 850 | — | **367** | 是 |
| listing `_sort=rating__desc` | 1,697 | 1,720 | **866** | 是 |
| listing 關鍵字 + 價格區間 | 754 | — | 764 | 是 |
| vendor 時薪區間 | 102 | — | 97 | 是 |
| WC 商店價格篩選 / 價格排序 | 94 / 111 | 88–94 | 109 / 88 | 是(WC 用 lookup 表,不經 router) |
(R-2.1 驗收,扁平表已從原生回填、含 PR-062c;兩輪量測見 `notes/tmdo-acceptance-2.1-2026-09-26.md`。)
- HivePress 搜尋仍帶 tax_query(排除一個 term),WordPress 因此加上 `GROUP BY posts.ID`,排序需要 temporary + filesort;這是目前 311–428 ms 的主要成本,尚未達到 R-2.1 的 300 ms 目標。
- PR-062b 之前,routed 搜尋因 `posts_request` 改寫而失去 WordPress 的 split query,改撈整列 `posts.*`:同一條 SQL 撈 `*` 要 590 ms,只撈 ID 為 312 ms。
- PR-062c 之前,依扁平欄位排序的 routed 查詢由 `wp_posts`(type / status 索引,約 56k 列)驅動、逐筆查扁平表再以 temporary table 排序;拿掉 GROUP BY 或只換 tie-break 都無效(約 300 ms),改由扁平表驅動並把 tie-break 改讀 `post_id` 後只剩單表 filesort:`meta_value_num` 263→147 ms、具名排序 250→142 ms、HP 時薪篩選 + 排序 300→36 ms(只計 SQL)。依日期排序的搜尋由扁平表驅動反而變慢(74→137 ms),所以 flat-first 只用在扁平欄位排序。
- 量測 gotcha:symlink 切換(`plugins/` → `plugins-dev`)後,PHP-FPM 的 opcache(`revalidate_path=0`)仍執行舊目標的程式,WP-CLI 不受影響;前台數字要先確認實際載入的檔案。
**WP_Query**(`tests/live/tools/qbench.php`,`fields=ids`,20 筆一頁):價格區間 296→76 ms、價格 + verified 360→53 ms、rating ≥ 4.5 248→33 ms、rate + `_thumbnail_id` EXISTS 24→9 ms;OR、多 post type、無 type 數值比較等維持原生,結果與原生相同。
**寫入鏈開銷**(單一程序,disabled 與 dual_write 交替兩輪取平均):
| 寫入 | disabled | dual_write | 增加 |
|---|---:|---:|---:|
| HP listing | 754 ms / 348 q | 976 ms / 434 q | +29% |
| WC product | 215 ms / 129 q | 489 ms / 206 q | +127% |
| vendor(user + post) | 444 ms / 162 q | 630 ms / 230 q | +42% |
WC product 的成本來自 `wc_product` 群組,Release 2.2 移除 WC 扁平化後消失。
## 優化候選(待使用者確認後排入 backlog)
| # | 候選 | 針對 | 預期效果 |
|---|---|---|---|
| O-1 | Entity 表把 `post_status`、`post_date` 一併存進來,並建 `(hp_status, post_date)` 等複合索引,讓搜尋不必 JOIN 回 `posts` 排序 | 情境 1、2 | 寬範圍搜尋改為走索引排序,避免 filesort |
| O-2 | Entity 表支援群組層級的複合索引宣告,例如 `(hp_verified, hp_price)`、`(hp_featured, hp_price)` | 情境 2 | 追上或超過 Hot 表 |
| O-3 | Hot 表的索引規則補上外鍵型 bigint 欄位(例如 `hp_vendor`) | 情境 3 | 3.4 秒降到 1 ms 以下 |
| O-4 | 提供總數快取,或讓路由後的查詢可以不跑 `SQL_CALC_FOUND_ROWS`(MySQL 8 已棄用) | 情境 1、2 | 省去每次搜尋都要算出全部符合列的成本 |
| O-5 | 評估 Hot 表與 Entity 表的整併,避免同一份資料存兩份 | 容量、寫入 | 省下約 229 MB 並減少一次寫入 |
+2
View File
@@ -1,5 +1,7 @@
# Integration Pattern Decision — 2026-04-25
> **2026-09 更新:** 2meet 自家外掛回到「partner 自行註冊」,做法見 `PARTNER-REGISTRATION.md`;對應的 8 個 AddOn 已淘汰。
> **v1.0.0 後記(2026-07-31):本文結論已被架構拆分取代。**
> 當時的兩個選項是「集中在核心」vs「各外掛自帶 bridge」。v1.0.0 走的是第三條路:
> 每個夥伴外掛對應**一個獨立 AddOn 外掛**(`2meet-data-optimizer-<partner>-addon`,共 11 個),
+115
View File
@@ -0,0 +1,115 @@
# Partner 外掛自行註冊指南
> 適用:2meet 自家外掛(bookings、collab、hub、infocards、mobile-bridge、playlist、quotation、spoke)。
> 這 8 個外掛原本各有一個 `2meet-data-optimizer-<slug>-addon`,替它們向核心註冊資料表和欄位。
> 這些 AddOn 已淘汰:資料結構的定義應該和資料的擁有者放在一起,所以改由 partner 外掛自己註冊。
> 啟用中的 AddOn 會在後台顯示淘汰通知(`TMDO_Deprecated_Addons_Notice`)。
>
> HivePress、WooCommerce、LatePoint 是第三方外掛,仍然保留各自的 AddOn。
## 1. 掛上 trait
核心提供 `TMDO_Anti_EAV_Aware` trait(`includes/trait-tmdo-anti-eav-aware.php`)。trait 由核心載入,所以只有在核心存在時才宣告 bridge 類別,把它放在獨立檔案:
```php
// 2meet-bookings.php(partner 主檔)
add_action(
'plugins_loaded',
static function () {
if ( ! trait_exists( 'TMDO_Anti_EAV_Aware' ) ) {
return; // 核心未啟用:什麼都不做。
}
require_once __DIR__ . '/includes/class-tmb-tmdo-bridge.php';
( new TMB_TMDO_Bridge() )->boot();
},
3
);
```
```php
// includes/class-tmb-tmdo-bridge.php
final class TMB_TMDO_Bridge {
use TMDO_Anti_EAV_Aware;
protected function plugin_slug(): string {
return '2meet-bookings';
}
public function boot(): void {
$this->bind_anti_eav_hooks();
}
// 覆寫需要的 on_register_*(),見下面兩節。
}
```
**時機**:核心在 `plugins_loaded:4` 觸發 `wpdo_register_*`,並在 `init:1` 再觸發一次。
建議掛在 `plugins_loaded:3`,這樣第一次觸發就能收到你的註冊,`init` 之前的讀寫也會被正確路由。
掛在 `:5` 之後也能運作(會在 `init:1` 補上),只是 `init` 之前的請求看不到你的欄位。
**名稱**:請用 `TMDO_Anti_EAV_Aware`。`WPDO_Anti_EAV_Aware` 是舊的 wp-data-optimizer 3.x 的 trait,由 mu-plugin `wpdo-policy-enforcer.php` 從已退役的路徑載入,沒有後面提到的 `post_types` 參數。
## 2. 註冊自有資料表
讓 `wp wpdo doctor` / benchmark 認得你的資料表,並做 schema 漂移檢查。以 bookings 為例:
```php
public function on_register_custom_tables( TMDO_Custom_Table_Registry $r ): void {
$this->register_custom_table(
$r,
array(
'table_name' => '2mb_services', // 不含 $wpdb->prefix
'primary_key' => 'id',
'post_type_link' => null, // 若每列對應一個 post,填 post_type(playlist 用 'hp_vendor')
'expected_columns' => array(
'id' => 'BIGINT',
'vendor_id' => 'BIGINT',
'title' => 'VARCHAR',
// …照 CREATE TABLE 逐欄列出
),
'indexes' => array(
'vendor_id' => array( 'vendor_id' ),
),
)
);
}
```
collab、playlist、quotation、mobile-bridge 的 AddOn 只做這件事:把各自 `includes/class-tmdo-<slug>.php` 裡 `register_custom_tables()` 的 `$tables` 陣列**原樣**搬進 partner 的 bridge 即可。
## 3. 註冊 entity 欄位群組(選用)
只有要把 meta 從 `wp_postmeta` / `wp_usermeta` 等 EAV 表移到扁平表時才需要:
```php
public function on_register_entity_fields(): void {
$this->register_entity_fields(
'post',
'tmb_service_core',
array(
array( 'key' => 'tmb_price', 'type' => 'decimal', 'searchable' => true ),
array( 'key' => 'tmb_status', 'type' => 'enum', 'options' => array( 'open', 'closed' ) ),
),
array( 'post_types' => array( 'tmb_service' ) )
);
}
```
- **`post_types`(post 群組建議必填)**:群組只套用在這些 post type。多個群組共用同一個 meta key 時,會依 post 本身的類型決定寫到哪張表;不屬於這些 post type 的 post,這個 key 不受管理(照舊寫原生 meta)。沒有宣告時,共用 key 會被「最後註冊的群組」整個接走。
- **定義一旦上線就不要改**:欄位定義的 hash 存在 `wpdo_registry_meta`,改了會對已有資料的扁平表執行 ALTER。要新增欄位時,開新群組。
- **群組名稱不可和別人重複**:同名但定義不同時,後註冊的會被忽略,並在 Site Health、後台 Doctor 分頁和 `wp wpdo doctor` 顯示 `[COLLISION]`。
## 4. 從 AddOn 遷移
1. 在 partner 的 bridge 裡註冊和 AddOn **完全相同**的資料表與群組(直接複製 AddOn 的定義)。
2. 部署 partner 新版,AddOn 先保持啟用。`wp wpdo doctor` 不應出現 `[COLLISION]`;兩邊定義相同時,重複註冊會被靜默忽略。
3. 停用 AddOn。
4. 再跑一次 `wp wpdo doctor`:資料表仍在清單中,而且不應出現 `[ORPHAN]`。如果出現,表示有群組沒有被 partner 接手;該實體是 aeav_only 時,核心會依 `wpdo_registry_meta` 暫時還原它,讓資料可以讀取,但仍應補上註冊。
5. 確認沒有問題後,刪除 AddOn 外掛。
hub、infocards、spoke 的 partner 外掛本身已經會自己註冊,AddOn 只是重複註冊;停用前照第 2 步確認沒有 `[COLLISION]` 即可。
## 相關文件
- `ENTITY_ADAPTER_COOKBOOK.md`:整合深度 Tier 1–3(`TMDO_API` facade、Schema Registry、自訂 adapter)。
- `INTEGRATION_PATTERN_DECISION.md`:2026-04 的「partner 自帶 bridge」決策紀錄;本指南回到這個做法。
+78
View File
@@ -0,0 +1,78 @@
# ADR-003:Hot zone 與 Entity 表收斂
- **狀態**:已接受(2026-09-26,選項 A)
- **日期**:2026-09-26
- **相關**:`BENCHMARK-SCALE.md`(O-3、O-5)、backlog PR-025〜PR-030
## 背景
post 的高頻欄位有兩套扁平化機制,資料可能存兩份:
| | Hot zone | Entity bridge |
|---|---|---|
| 表 | `wp_wpdo_hot_{post_type}`(每個 post type 一張) | `wp_wpdo_post_{group}`(每個群組一張) |
| 註冊 | `TMDO_Schema_Registry`(zone = hot) | `TMDO_Entity_Registry::register_group()` |
| 寫入 | `TMDO_Sync_Bridge`(zone 模組進入 dual_write 後) | `TMDO_Hook_Bus`(實體 mode ≥ dual_write) |
| 查詢改寫 | `TMDO_Query_Router`(`hot_*` 模組 cutover 後) | `TMDO_Post_Query_Router`(post mode ≥ shadow_read) |
| 生命週期 | 每個 zone 模組 7 狀態 FSM | 每個實體 4 模式(disabled → aeav_only) |
| 涵蓋實體 | 只有 post | post、user、term、comment |
| 可完全取代原生 meta | 否(cutover 後原生仍保留) | 是(aeav_only) |
| 其他 | 單欄 `indexed`、自訂 `data_type` | post_type 範圍(PR-033)、schema hash、衝突偵測、孤兒還原、回寫原生(PR-017/018/031) |
Warm(瀏覽數 TTL)、Cold(JSON blob)、Archive(已刪除文章的 meta)是 Entity 沒有的能力,**不在本決策範圍**,保持原狀。
## 事實
**效能**(100k vendor / 1M listing,`BENCHMARK-SCALE.md`):
| 情境 | Hot | Entity |
|---|---:|---:|
| 價格區間搜尋 p50 | 558 ms | 652 ms |
| 價格區間 + verified p50 | **277 ms**(有複合索引) | 337 ms |
| vendor 名下 listing p50 | 3,405 ms(無索引) | **0.28 ms** |
| 單筆 / 列表預載 | 相同 | 相同 |
| 容量 | 229 MB | 379 MB |
唯一 Hot 領先的是有複合索引的情境;Entity 支援複合索引後(PR-027)即可追平。
**各站使用現況**(2026-09-26 唯讀查詢):
| 站 | Hot / Cold 表 | zone 模組 | post entity |
|---|---|---|---|
| dev20 / dev21 / dev22 | 無 | 全 idle | aeav_only(dev21 約 400 萬列) |
| dev23 / 24 / 30 / 31 / 40 | 無 | 全 idle | disabled |
| dev34(舊 WPDO 1.3.37) | hot_hp_listing 486、hot_hp_vendor 233 | cutover | disabled |
| dev10 DB(舊版) | hot_hp_listing 491、hot_hp_vendor 231 | cutover | aeav_only(約 3,900 列) |
- **沒有任何 2meet-DO 站在用 Hot zone**;有扁平資料的站全部走 Entity。
- 只有兩個舊系統的站有 Hot 資料,而它們在 cutover 仍雙寫 postmeta,Hot 表只是副本(PR-022 已確認以 postmeta 為準)。
**只由 Hot zone 註冊、Entity 群組沒有涵蓋的 HP 欄位**:listing / request 的 `hp_drafted`;bookings 的 `hp_start_time`、`hp_end_time`、`hp_quantity`;memberships 的 `hp_expire_period`、`hp_primary`、`hp_expired_time`。
## 選項
**A. 收斂到 Entity(建議)**
post 高頻欄位只用 Entity。Hot zone 凍結:不再新增註冊,程式碼暫留只為讀取舊資料,確認沒有站使用後再移除。
- 優點:一套路由、一份資料;Entity 能力是 Hot 的超集;現況已經全部在用 Entity,不需要搬資料。
- 代價:Hot 獨有的欄位(上面那幾個)要改成新的 Entity 群組;情境 2 要靠 PR-027 的複合索引追平。
**B. 兩套並存**
維持現狀。代價是兩套寫入和查詢路徑都要維護,同一個 key 可能存兩份,O-3 之類的最佳化要做兩次。
**C. 收斂到 Hot**
不可行:Hot 只支援 post,也沒有 aeav_only、範圍宣告、回寫工具。
## 建議(選項 A)的後續步驟
1. **PR-026(Hot 表 bigint 索引)標為 `[!]` 不做**:沒有站在用 Hot zone。
2. **PR-027(Entity 複合索引)照常做**:這是情境 2 追平 Hot 的方式。
3. **新增 PR(HP AddOn)**:把 Hot 獨有的欄位改註冊成新的 Entity 群組(例如 `hp_booking_core`、`hp_membership_core`,以及 listing / request 的補充群組);既有群組的定義不能改。
4. **新增 PR(HP AddOn)**:HP adapter 停止註冊 hot 欄位;核心對 zone = hot 的新註冊記錄 deprecated 警告。
5. **之後**:dev34(PR-022)和 dev10 DB(PR-023)遷出後,再評估移除 `TMDO_Zone_Hot`、`TMDO_Sync_Bridge` 的 hot 路徑與 `TMDO_Query_Router`。
## 決定
**選項 A:收斂到 Entity。**(使用者 2026-09-26 決定)
Hot zone 凍結,依上面的後續步驟執行;backlog 已同步調整(PR-026 標為 `[!]`,新增 HP AddOn 的兩個 PR)。
+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 的後續步驟)。
@@ -284,7 +284,6 @@ class TMDO_Module_Detector {
$active = match ( $plugin ) {
'hivepress' => TMDO_Compatibility::is_hivepress_active(),
'woocommerce' => TMDO_Compatibility::is_woocommerce_active(),
'hpct' => TMDO_Compatibility::is_hpct_active(),
'latepoint' => TMDO_Compatibility::is_latepoint_active(),
default => false,
};
+2 -44
View File
@@ -29,52 +29,10 @@ if ( ! defined( 'ABSPATH' ) ) {
class TMDO_Module_Rules {
/**
* Default rules for the 15 known modules. Use `get()` to apply filter.
* Default rules for the 8 known modules. Use `get()` to apply filter.
*/
private const DEFAULT_RULES = array(
// ─── HPCT modules(HivePress + HPCT)────────────────────────────
'reviews' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_review',
'min_post_count' => 100,
'description' => '若使用 HivePress reviews 且累積評論 ≥ 100 條,啟用後可顯著降低 listing 查詢的 JOIN 成本。',
),
'messages' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_message_thread',
'min_post_count' => 50,
'description' => 'HivePress messages 累積對話數 ≥ 50 時,啟用 module 可加速 inbox / unread count 查詢。',
),
'favorites' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_favorite',
'min_post_count' => 200,
'description' => '使用者 favorite 累積 ≥ 200 條時,啟用 module 把 favorite meta 從 wp_postmeta 搬出。',
),
'memberships' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_membership',
'min_post_count' => 50,
'description' => 'HivePress memberships 啟用且 ≥ 50 條訂閱時,membership 過期檢查會更快。',
),
'statistics' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_listing',
'min_post_count' => 500,
'description' => 'Listing ≥ 500 條時,view_count / favorite_count 等高頻統計搬到 stats module 可大幅減少 wp_postmeta 寫入。',
),
'requests' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_request',
'min_post_count' => 50,
'description' => 'HivePress requests / quotes 累積 ≥ 50 條時建議啟用。',
),
'listing_meta' => array(
'compat_required' => array( 'hivepress' ),
'post_type_required' => 'hp_listing',
'min_post_count' => 100,
'description' => '所有 listing meta 集中管理;listing ≥ 100 時可省下大量 postmeta JOIN。',
),
// ─── AddOn modules ──────────────────────────────────────────────
'wc_orders' => array(
'compat_required' => array( 'woocommerce' ),
'post_type_required' => 'shop_order',
+27 -14
View File
@@ -40,7 +40,7 @@ class TMDO_Cache_Layer {
}
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
if ( empty( $cold_keys ) ) {
if ( empty( $cold_keys ) || ! self::cold_active( $post_type ) ) {
return;
}
@@ -49,7 +49,7 @@ class TMDO_Cache_Layer {
// Check which IDs are already cached.
$uncached = array();
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 ) {
$uncached[] = (int) $pid;
}
@@ -94,7 +94,7 @@ class TMDO_Cache_Layer {
// Cache all results (including empty arrays for posts with no cold data).
foreach ( $uncached as $pid ) {
$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 {
$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;
}
// Force a fresh read from DB (bypasses cache).
$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.
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.
*
* 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.
*/
public static function flush_group( string $post_type ): void {
$group = 'wpdo_cold_' . sanitize_key( $post_type );
if ( function_exists( 'wp_cache_flush_group' ) ) {
wp_cache_flush_group( $group );
}
// If wp_cache_flush_group is not available (older WP or no object cache),
// individual entries will expire via TTL.
$versions = get_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, array() );
$versions = is_array( $versions ) ? $versions : array();
$versions[ $post_type ] = (int) ( $versions[ $post_type ] ?? 0 ) + 1;
update_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, $versions, true );
}
/**
@@ -170,7 +183,7 @@ class TMDO_Cache_Layer {
return array(
'groups' => $groups,
'prefetch_support' => true,
'flush_support' => function_exists( 'wp_cache_flush_group' ),
'flush_support' => true,
);
}
+21 -54
View File
@@ -37,8 +37,6 @@ class TMDO_Compatibility {
}
self::$detected = array(
'hpct_active' => self::is_hpct_active(),
'hpct_imported' => self::is_hpct_imported(),
'hivepress' => self::is_hivepress_active(),
'woocommerce' => self::is_woocommerce_active(),
);
@@ -46,48 +44,16 @@ class TMDO_Compatibility {
return self::$detected;
}
/**
* Can WPDO register its HPCT-inherited module interceptors?
*
* True when:
* - hp-custom-tables is NOT active, OR
* - hp-custom-tables IS active but already imported into WPDO.
*/
public static function can_register_hpct_hooks(): bool {
$compat = self::check();
// HPCT not active → WPDO can freely register.
if ( ! $compat['hpct_active'] ) {
return true;
}
// HPCT is active but already imported → WPDO takes over.
if ( $compat['hpct_imported'] ) {
return true;
}
// HPCT is active and NOT imported → defer to HPCT.
return false;
}
/**
* Can WPDO register zone-specific interceptors (hot/warm/cold/archive)?
*
* Zone interceptors are always safe to register — they operate on
* WPDO's own tables and do not conflict with HPCT.
* WPDO's own tables.
*/
public static function can_register_zone_hooks(): bool {
return true;
}
/**
* Should WPDO show an admin notice about HPCT import?
*/
public static function should_show_hpct_notice(): bool {
$compat = self::check();
return $compat['hpct_active'] && ! $compat['hpct_imported'];
}
/**
* Is HivePress core active?
*/
@@ -102,6 +68,26 @@ class TMDO_Compatibility {
return class_exists( 'WooCommerce' );
}
/**
* Integrations that are active while their AddOn is not.
*
* Core does not know these plugins; their fields are only optimised by the
* matching AddOn. The AddOn is detected by its bootstrap class, which its
* main file declares as soon as the plugin loads.
*
* @return array<string, string> Plugin name → AddOn plugin slug.
*/
public static function missing_integration_addons(): array {
$missing = array();
if ( self::is_hivepress_active() && ! class_exists( 'TMDO_HP_Bootstrap' ) ) {
$missing['HivePress'] = '2meet-data-optimizer-hivepress-addon';
}
if ( self::is_woocommerce_active() && ! class_exists( 'TMDO_Woocommerce_Bootstrap' ) ) {
$missing['WooCommerce'] = '2meet-data-optimizer-woocommerce-addon';
}
return $missing;
}
/**
* Is LatePoint plugin active? (v2.5.0 M16 polish — for module detector.)
*
@@ -113,23 +99,4 @@ class TMDO_Compatibility {
|| defined( 'LATEPOINT_VERSION' )
|| function_exists( 'latepoint_get_settings' );
}
// ── Detection Methods ────────────────────────────────────────────────
/**
* Is HP Custom Tables plugin active (loaded)?
*
* Checks for the HPCT_Loader class, which is always loaded
* when hp-custom-tables is active.
*/
public static function is_hpct_active(): bool {
return class_exists( 'HPCT_Loader' ) || defined( 'HPCT_VERSION' );
}
/**
* Has HPCT been imported into WPDO?
*/
private static function is_hpct_imported(): bool {
return (bool) get_option( 'wpdo_hpct_imported', false );
}
}
+29 -287
View File
@@ -31,31 +31,10 @@ class TMDO_Core {
// Fire the schema registry hook so integrations can register their fields.
$registry = TMDO_Schema_Registry::instance();
// v2.0.x: Pre-register the 7 partner plugin integrations BEFORE firing the
// `wpdo_register_fields` and `wpdo_register_custom_tables` hooks. Each
// `register()` is a thin `add_action()` wrapper guarded by a partner-plugin
// `class_exists()` probe (TMDO_Infocards / Bookings / Quotation / Events /
// Mobile_Bridge / Collab / Playlist). Without this, partner add_actions
// arrive too late and the do_action below misses them — observed via
// 2meet-infocards 9 hp_vendor meta keys never landing in Schema Registry.
// register() is side-effect-free aside from add_action(), so pre-registering
// is provably safe (no hidden dependency on later-state global init).
// v2.1.3 R1 (B step): TMDO_Events removed — 2meet-events now self-registers
// via its own bridge class. Other partners still pre-registered here pending
// their own decentralized migrations (R1 continuation in v2.2.x).
$partner_integrations = array(
'TMDO_Infocards',
'TMDO_Bookings',
'TMDO_Quotation',
'TMDO_Mobile_Bridge',
'TMDO_Collab',
'TMDO_Playlist',
'TMDO_WooCommerce',
'TMDO_Member_Fields',
'TMDO_Post_Fields',
'TMDO_Hivepress_Term_Comment_Fields',
);
foreach ( $partner_integrations as $cls ) {
// Core-owned field providers register before `wpdo_register_fields` fires.
// AddOns wire their own integrations from their bootstraps
// (plugins_loaded:3 or :6), so core does not probe for them here.
foreach ( array( 'TMDO_Member_Fields', 'TMDO_Post_Fields' ) as $cls ) {
if ( class_exists( $cls ) ) {
call_user_func( array( $cls, 'register' ) );
}
@@ -74,7 +53,6 @@ class TMDO_Core {
// AFTER plugins_loaded:4 (e.g., partners loaded by HP modules at plugins_loaded:5+,
// or lazy-loaded modules booted on init:0). Schema_Registry::register() has
// internal dedup, so re-firing is idempotent — same partner calls won't double-register.
// This deprecates the need for the TMDO_Core hardcoded partner pre-registration list above.
//
// v2.1.6: also re-fire `wpdo_register_entity_fields` so partner plugins booting on
// plugins_loaded:20 (e.g. 2meet-spoke-sso) can register entity-based user/term/comment
@@ -93,6 +71,12 @@ class TMDO_Core {
if ( class_exists( 'TMDO_Custom_Table_Registry' ) ) {
TMDO_Custom_Table_Registry::instance()->fire_registration();
}
// Every provider has registered by now: re-register groups whose flat
// table still holds data but whose plugin is gone, so aeav_only
// values keep reading back (routing only — never DDL).
if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) {
TMDO_Schema_Manager::restore_orphaned_groups();
}
if ( class_exists( 'TMDO_Schema_Manager' ) && class_exists( 'TMDO_Entity_Registry' ) ) {
$pending = TMDO_Entity_Registry::get_pending_schemas();
if ( ! empty( $pending ) ) {
@@ -111,17 +95,6 @@ class TMDO_Core {
// Cache Layer hooks (Zone C prefetch + save_post warming).
TMDO_Cache_Layer::register_hooks();
// Register HPCT-inherited module interceptors if allowed.
if ( TMDO_Compatibility::can_register_hpct_hooks() ) {
$this->register_hpct_interceptors();
}
// v2.1.2 fix: WC commission interceptor is independent of HPCT — writes
// to its own wp_wpdo_wc_commissions table. Register whenever WC is active.
if ( class_exists( 'TMDO_WooCommerce' ) && TMDO_WooCommerce::is_active() && class_exists( 'TMDO_WC_Orders_Interceptor' ) ) {
( new TMDO_WC_Orders_Interceptor() )->register_hooks();
}
// Zone interceptors are always safe to register.
if ( TMDO_Compatibility::can_register_zone_hooks() ) {
$this->register_zone_hooks();
@@ -136,6 +109,9 @@ class TMDO_Core {
// v2.0.0: boot the unified Hook Bus when explicitly enabled.
TMDO_Hook_Bus_Bridge::maybe_init_hook_bus();
// Keep post_status / post_date copies on post entity tables current.
TMDO_Post_Status_Sync::register();
// Register audit log listeners for wpdo_after_write / wpdo_after_delete.
if ( class_exists( 'TMDO_Audit_Logger' ) ) {
TMDO_Audit_Logger::init();
@@ -149,18 +125,9 @@ class TMDO_Core {
TMDO_Conflict_Monitor::register_hooks();
}
// v2.0.x: partner plugin integrations have been pre-registered earlier
// (see top of run() before the wpdo_register_fields do_action). Removing
// the duplicate late-binding block — see commit log for time-ordering bug.
// Schedule cron events.
$this->schedule_cron();
// Admin notice for HPCT import.
if ( is_admin() && TMDO_Compatibility::should_show_hpct_notice() ) {
add_action( 'admin_notices', array( $this, 'render_hpct_notice' ) );
}
// v2.6.6: async entity backfill cron handler.
add_action( 'wpdo_entity_backfill_batch', array( $this, 'run_entity_backfill_batch' ), 10, 2 );
@@ -182,14 +149,6 @@ class TMDO_Core {
add_action( TMDO_Comment_Stress_Tester::CRON_HOOK, array( __CLASS__, 'run_comment_stress_test_batch' ) );
}
// v2.11.5: HivePress per-post transient cache → wp_options reroute.
// Filters registered on `init` so HivePress's own bootstrap (plugins_loaded:5)
// runs first and our filter chain catches the actual cache writes happening
// during save_post and term-cache rebuilds.
if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 );
}
// v2.12.1: Term + Comment garbage write-time filter
// (silent-drop _wxr_import_* / _2meet_demo_* / orphan post-meta keys).
if ( class_exists( 'TMDO_Term_Comment_Garbage_Filter' ) ) {
@@ -282,218 +241,6 @@ class TMDO_Core {
do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
}
/**
* Register default HivePress field mappings.
*
* These are the known HivePress meta_keys and their zone classifications.
*
* @param TMDO_Schema_Registry $registry The schema registry instance.
* @return void
*/
private function register_hivepress_defaults( TMDO_Schema_Registry $registry ): void {
// Zone A (Hot) — Listing search/filter fields.
$registry->register_many(
'hivepress',
array(
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_price',
'indexed' => true,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_featured',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_featured',
'indexed' => true,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_verified',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_verified',
'indexed' => true,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_expired_time',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_expired_time',
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_featured_time',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_featured_time',
),
// Vendor hot fields.
array(
'post_type' => 'hp_vendor',
'meta_key' => 'hp_verified',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_verified',
'indexed' => true,
),
array(
'post_type' => 'hp_vendor',
'meta_key' => 'hp_hourly_rate',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_hourly_rate',
'indexed' => true,
),
array(
'post_type' => 'hp_vendor',
'meta_key' => 'hp_rating_count',
'zone' => 'hot',
'data_type' => 'int(11) NOT NULL DEFAULT 0',
'column' => 'hp_rating_count',
'indexed' => false,
),
)
);
// Zone C (Cold) — Profile/display fields.
$registry->register_many(
'hivepress',
array(
array(
'post_type' => 'hp_vendor',
'meta_key' => 'hp_description',
'zone' => 'cold',
'cache_group' => 'wpdo_cold_hp_vendor',
'cache_ttl' => HOUR_IN_SECONDS,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_description',
'zone' => 'cold',
'cache_group' => 'wpdo_cold_hp_listing',
'cache_ttl' => HOUR_IN_SECONDS,
),
)
);
// ── HivePress extension fields (filled the 47% → 94% coverage gap) ──
// Reviews extension (hp_review post type).
$registry->register_many(
'hivepress-reviews',
array(
array(
'post_type' => 'hp_review',
'meta_key' => 'hp_rating',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_rating',
'indexed' => true,
),
array(
'post_type' => 'hp_vendor',
'meta_key' => 'hp_rating',
'zone' => 'hot',
'data_type' => 'decimal(3,2) NOT NULL DEFAULT 0',
'column' => 'hp_rating',
'indexed' => true,
),
array(
'post_type' => 'hp_review',
'meta_key' => 'hp_text',
'zone' => 'cold',
'cache_group' => 'wpdo_cold_hp_review',
'cache_ttl' => HOUR_IN_SECONDS,
),
)
);
// Bookings extension (hp_booking post type).
$registry->register_many(
'hivepress-bookings',
array(
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_start_time',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_start_time',
'indexed' => true,
),
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_end_time',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_end_time',
'indexed' => true,
),
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_status',
'zone' => 'hot',
'data_type' => "varchar(20) NOT NULL DEFAULT ''",
'column' => 'hp_status',
'indexed' => true,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_booking_enabled',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_booking_enabled',
'indexed' => true,
),
)
);
// Marketplace / Requests extension.
$registry->register_many(
'hivepress-marketplace',
array(
array(
'post_type' => 'hp_request',
'meta_key' => 'hp_request_category',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_request_category',
'indexed' => true,
),
array(
'post_type' => 'hp_offer',
'meta_key' => 'hp_amount',
'zone' => 'hot',
'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
'column' => 'hp_amount',
'indexed' => true,
),
array(
'post_type' => 'hp_offer',
'meta_key' => 'hp_request',
'zone' => 'hot',
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
'column' => 'hp_request',
'indexed' => true,
),
)
);
}
/**
* Register HPCT-inherited module interceptors and query interceptors.
*/
private function register_hpct_interceptors(): void {
// 7 HPCT-era meta interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
// 1 LatePoint interceptor 已搬到 2meet-data-optimizer-latepoint-addon。
// 5 query interceptors 已搬到 2meet-data-optimizer-hivepress-addon。
// 此方法保留為空殼以保持 ABI 相容;AddOn 各自於自身 bootstrap 內 register_hooks。
}
/**
* Register zone-specific hooks.
*/
@@ -514,6 +261,9 @@ class TMDO_Core {
$post_router = new TMDO_Post_Query_Router();
$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.
add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) );
@@ -549,6 +299,19 @@ class TMDO_Core {
// v2.3.0 M6: daily health probe cron.
add_action( 'wpdo_daily_health_check', array( 'TMDO_Health_Cron', 'run' ) );
// 1.2.0: daily flat-vs-native drift sample (read by the post query router).
add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );
// 1.2.0: rebuild flat from native after writes bypassed it (entity was
// disabled, or the Hook Bus was off).
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );
add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );
add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );
add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );
// 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).
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
@@ -791,27 +554,6 @@ class TMDO_Core {
TMDO_Zone_Archive::sweep();
}
/**
* Render admin notice when hp-custom-tables is detected but not imported.
*/
public function render_hpct_notice(): void {
$import_url = admin_url( 'tools.php?page=wp-data-optimizer&tab=hpct-import' );
?>
<div class="notice notice-warning is-dismissible">
<p>
<strong>WP Data Optimizer:</strong>
<?php
printf(
/* translators: %s: URL to import page */
esc_html__( 'HP Custom Tables 外掛已偵測到。請前往 %s 匯入其設定,然後停用 HP Custom Tables。', '2meet-data-optimizer' ),
'<a href="' . esc_url( $import_url ) . '">' . esc_html__( 'HPCT 匯入頁面', '2meet-data-optimizer' ) . '</a>'
);
?>
</p>
</div>
<?php
}
/**
* WP Cron handler: process one backfill batch for an entity/group pair.
*
+6 -33
View File
@@ -27,17 +27,9 @@ class TMDO_Feature_Flags {
private const OPTION_KEY = 'wpdo_features';
/**
* All known HPCT-inherited modules.
* These modules operate on existing hpct_* tables.
* Modules driven by AddOn interceptors (WooCommerce commissions, LatePoint).
*/
public const HPCT_MODULES = array(
'reviews',
'messages',
'favorites',
'memberships',
'statistics',
'requests',
'listing_meta',
public const ADDON_MODULES = array(
'wc_orders',
'latepoint',
);
@@ -300,7 +292,7 @@ class TMDO_Feature_Flags {
$saved = array();
}
$all_modules = array_merge( self::HPCT_MODULES, self::ZONE_MODULES );
$all_modules = array_merge( self::ADDON_MODULES, self::ZONE_MODULES );
$defaults = array_fill_keys( $all_modules, 'idle' );
self::$cache = array_merge( $defaults, $saved );
@@ -308,13 +300,13 @@ class TMDO_Feature_Flags {
}
/**
* Return only HPCT-inherited module states.
* Return only AddOn module states.
*
* @return array<string, string>
*/
public static function hpct_modules(): array {
public static function addon_modules(): array {
$all = self::all();
return array_intersect_key( $all, array_flip( self::HPCT_MODULES ) );
return array_intersect_key( $all, array_flip( self::ADDON_MODULES ) );
}
/**
@@ -326,23 +318,4 @@ class TMDO_Feature_Flags {
$all = self::all();
return array_intersect_key( $all, array_flip( self::ZONE_MODULES ) );
}
/**
* Map an HPCT feature flag status to a WPDO state.
*
* Used during HPCT import to translate HPCT's 4-state model
* to WPDO's 7-state model.
*
* @param string $hpct_status HPCT status (disabled/migrating/verified/enabled).
* @return string WPDO state.
*/
public static function map_hpct_status( string $hpct_status ): string {
return match ( $hpct_status ) {
'disabled' => 'idle',
'migrating' => 'backfill',
'verified' => 'cutover',
'enabled' => 'complete',
default => 'idle',
};
}
}
+1 -8
View File
@@ -54,7 +54,7 @@ final class TMDO_Hook_Bus_Bridge {
if ( null !== self::$cache ) {
return self::$cache;
}
$value = get_option( self::OPTION, '1' ); // Default ON as of v2.5.4.
$value = get_option( self::OPTION, '0' );
self::$cache = ( '1' === (string) $value || true === $value );
return self::$cache;
}
@@ -98,13 +98,6 @@ final class TMDO_Hook_Bus_Bridge {
* @var string[]
*/
private const COEXIST_WHITELIST = array(
'TMDO_Reviews_Interceptor',
'TMDO_Messages_Interceptor',
'TMDO_Favorites_Interceptor',
'TMDO_Memberships_Interceptor',
'TMDO_Statistics_Interceptor',
'TMDO_Requests_Interceptor',
'TMDO_Listing_Meta_Interceptor',
'TMDO_WC_Orders_Interceptor',
'TMDO_LatePoint_Interceptor',
'TMDO_Sync_Bridge',
+183 -83
View File
@@ -40,6 +40,9 @@ class TMDO_Installer {
*/
private const SCHEMA_VERSION = '2.1.0';
/** Set once maybe_preserve_misc_bucket_default() has run for this site. */
public const MISC_BUCKET_DEFAULT_FLAG = 'wpdo_misc_bucket_default_v2';
/** Allowed SQL base types for hot-zone column definitions. */
private const ALLOWED_COL_BASE_TYPES = array(
'bigint',
@@ -214,6 +217,16 @@ class TMDO_Installer {
// Idempotent — only creates tables that don't already exist.
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 );
// Register wpdo_features with autoload=no so it doesn't inflate every page load.
@@ -221,47 +234,12 @@ class TMDO_Installer {
add_option( 'wpdo_features', array(), '', 'no' );
}
// v2.5.4: one-time migration — enable Hook Bus + activate user/term/comment bridge.
self::maybe_autoactivate_entity_bridge();
// v2.15.0: one-time crypto migration — re-encrypt v1 CBC ciphertext as
// v2 GCM. Idempotent best-effort: skipped if no v1 ciphertext present
// or if the migration flag is already set.
self::maybe_migrate_crypto_v1_to_v2();
}
/**
* One-time migration: enable Hook Bus and set user/term/comment to dual_write.
*
* Runs once per site (guarded by wpdo_entity_bridge_autoactivated_v2 flag).
* Safe: dual_write still writes native EAV — zero data-loss risk.
*
* @return void
*/
private static function maybe_autoactivate_entity_bridge(): void {
if ( get_option( 'wpdo_entity_bridge_autoactivated_v2' ) ) {
return;
}
// Enable Hook Bus.
update_option( 'wpdo_hook_bus_enabled', '1', false );
// Upgrade user/term/comment from disabled → dual_write.
$modes = get_option( 'wpdo_bridge_modes', array() );
if ( ! is_array( $modes ) ) {
$modes = array();
}
foreach ( array( 'user', 'term', 'comment' ) as $type ) {
if ( ! isset( $modes[ $type ] ) || 'disabled' === $modes[ $type ] ) {
$modes[ $type ] = 'dual_write';
}
}
// post entity is intentionally left alone — it uses the legacy Feature_Flags FSM.
update_option( 'wpdo_bridge_modes', $modes, false );
update_option( 'wpdo_entity_bridge_autoactivated_v2', '1', false );
}
/**
* One-time best-effort crypto migration v1 (CBC) → v2 (GCM) (v2.15.0).
*
@@ -299,6 +277,128 @@ class TMDO_Installer {
if ( get_option( 'wpdo_db_version' ) !== self::SCHEMA_VERSION ) {
self::install();
}
self::maybe_preserve_misc_bucket_default();
self::maybe_init_post_scoped_routing();
}
/**
* One-time default for post_type-scoped entity routing.
*
* Autoloaded because TMDO_Entity_Registry::get_field() reads it per lookup.
*
* @return void
*/
public static function maybe_init_post_scoped_routing(): void {
global $wpdb;
if ( false !== get_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, false ) || ! class_exists( 'TMDO_Mode_Manager' ) ) {
return;
}
$has_rows = false;
$tables = (array) $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . 'wpdo_post_' ) . '%' ) );
foreach ( $tables as $table ) {
if ( $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from SHOW TABLES.
$has_rows = true;
break;
}
}
add_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, self::post_scoped_routing_default( TMDO_Mode_Manager::get( 'post' ), $has_rows ), '', true );
}
/**
* Whether post_type-scoped routing can start on right away.
*
* Existing post flat rows were written with legacy routing (a key shared by
* several groups went to the last one registered), so they may sit in
* another post type's table. Scoped lookups stay off there until the rows
* are reconciled; with the post bridge disabled nothing reads them.
*
* @param string $post_mode Current post entity bridge mode.
* @param bool $has_post_flat_rows Whether any wpdo_post_* table has rows.
* @return string '1' to enable, '0' to wait for reconcile.
*/
public static function post_scoped_routing_default( string $post_mode, bool $has_post_flat_rows ): string {
return ( 'disabled' === $post_mode || ! $has_post_flat_rows ) ? '1' : '0';
}
/**
* One-time migration for the misc bucket default flip (on → off).
*
* The bucket used to default to enabled; its rows are only readable while it
* is on. Sites that already hold misc rows and never set the toggle get '1'
* persisted so turning the default off cannot hide their data. Runs from
* maybe_upgrade() rather than install() because install() only runs when
* SCHEMA_VERSION changes; the flag is autoloaded since it is read per request.
*
* @return void
*/
public static function maybe_preserve_misc_bucket_default(): void {
if ( get_option( self::MISC_BUCKET_DEFAULT_FLAG ) || ! class_exists( 'TMDO_Term_Comment_Misc_Bucket' ) ) {
return;
}
$unset = false === get_option( TMDO_Term_Comment_Misc_Bucket::OPT_ENABLED );
$has_rows = TMDO_Term_Comment_Misc_Bucket::count_rows( 'term' ) > 0
|| TMDO_Term_Comment_Misc_Bucket::count_rows( 'comment' ) > 0;
if ( $unset && $has_rows ) {
update_option( TMDO_Term_Comment_Misc_Bucket::OPT_ENABLED, '1', false );
}
update_option( self::MISC_BUCKET_DEFAULT_FLAG, '1', true );
}
/**
* Reasons this site's wpdo_* tables cannot be dropped yet; empty means safe.
*
* Reads the site's own options rather than Mode_Manager / Feature_Flags,
* whose per-request caches do not follow switch_to_blog() during uninstall.
*
* @return string[]
*/
public static function uninstall_blockers(): array {
global $wpdb;
$blockers = array();
$modes = get_option( 'wpdo_bridge_modes', array() );
foreach ( is_array( $modes ) ? $modes : array() as $entity => $mode ) {
if ( 'aeav_only' === $mode ) {
$blockers[] = sprintf( '%1$s 的 bridge 模式是 aeav_only:資料只存在 wpdo_%1$s_* 扁平表。', $entity );
}
}
foreach ( array( 'wpdo_term_misc', 'wpdo_comment_misc' ) as $name ) {
if ( self::table_has_rows( $wpdb->prefix . $name ) ) {
$blockers[] = sprintf( '%s 有資料:這些 term / comment meta 只存在 misc 表。', $name );
}
}
$features = get_option( 'wpdo_features', array() );
foreach ( TMDO_Feature_Flags::ZONE_MODULES as $module ) {
$state = is_array( $features ) ? ( $features[ $module ] ?? 'idle' ) : 'idle';
if ( in_array( $state, array( 'cleanup', 'complete' ), true ) ) {
$blockers[] = sprintf( '區域模組 %1$s 處於 %2$s:讀取已改走 wpdo_* 表。', $module, $state );
}
}
return $blockers;
}
/**
* Whether a table exists and holds at least one row.
*
* @param string $table Full table name.
* @return bool
*/
private static function table_has_rows( string $table ): bool {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ) {
return false;
}
return null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" );
// phpcs:enable
}
// ── v2.0.0 entity + audit + shadow_diffs schema (PR-2) ─────────────────
@@ -681,28 +781,45 @@ class TMDO_Installer {
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.
*/
public static function deactivate(): void {
wp_clear_scheduled_hook( 'wpdo_warm_cleanup' );
wp_clear_scheduled_hook( 'wpdo_archive_sweep' );
// 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' );
foreach ( self::CRON_HOOKS as $hook ) {
wp_unschedule_hook( $hook );
}
}
// ── v2.14.0: Multisite-aware site cleanup ─────────────────────────────────
@@ -753,18 +870,11 @@ class TMDO_Installer {
$wpdb->prefix . 'wpdo_user_core_profile',
$wpdb->prefix . 'wpdo_user_social',
$wpdb->prefix . 'wpdo_user_commerce',
$wpdb->prefix . 'wpdo_user_hp_user',
$wpdb->prefix . 'wpdo_user_admin_prefs',
$wpdb->prefix . 'wpdo_post_wp_core',
$wpdb->prefix . 'wpdo_post_attachment',
$wpdb->prefix . 'wpdo_post_wc_product',
$wpdb->prefix . 'wpdo_post_hp_listing_core',
$wpdb->prefix . 'wpdo_post_hp_request_core',
$wpdb->prefix . 'wpdo_post_hp_vendor_core',
$wpdb->prefix . 'wpdo_post_nav_menu_item',
$wpdb->prefix . 'wpdo_hot_hp_listing',
$wpdb->prefix . 'wpdo_term_hp_taxonomy',
$wpdb->prefix . 'wpdo_comment_hp_review',
$wpdb->prefix . 'wpdo_term_misc',
$wpdb->prefix . 'wpdo_comment_misc',
);
@@ -882,40 +992,26 @@ class TMDO_Installer {
// Sweep any remaining wpdo_* options (stragglers introduced post-v2.14.0
// or by 3rd-party hooks). Validates name pattern before delete to avoid
// accidental option removal.
// 1.4.2: tmdo_* options and their transients too (tmdo_schema_deferred,
// tmdo_entity_indexes, tmdo_cov_* …) — a reinstall saw stale ones.
// tmdo_latepoint_* belongs to the LatePoint add-on.
foreach ( array( 'wpdo_', 'tmdo_', '_transient_tmdo_', '_transient_timeout_tmdo_' ) as $prefix ) {
$residual = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like( 'wpdo_' ) . '%'
$wpdb->esc_like( $prefix ) . '%'
)
);
foreach ( (array) $residual as $opt ) {
if ( preg_match( '/^wpdo_[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
if ( preg_match( '/^(_transient_(timeout_)?)?(wpdo|tmdo)_(?!latepoint_)[a-zA-Z0-9_]+$/', $opt ) && delete_option( $opt ) ) {
++$options_deleted;
}
}
}
// ── 5) Clear cron events ──────────────────────────────────────────────
$crons = 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',
'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 );
foreach ( self::CRON_HOOKS as $hook ) {
if ( wp_unschedule_hook( $hook ) ) {
++$crons_cleared;
}
}
@@ -1272,7 +1368,10 @@ class TMDO_Installer {
*/
private static function run_dbdelta(): void {
global $wpdb;
// Same guard as install_v2_tables(): integration tests provide a stub dbDelta().
if ( ! function_exists( 'dbDelta' ) ) {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
}
$charset = $wpdb->get_charset_collate();
$p = $wpdb->prefix;
@@ -1350,7 +1449,8 @@ class TMDO_Installer {
original_meta_id bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (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};";
foreach ( $sqls as $sql ) {
+23 -6
View File
@@ -48,7 +48,7 @@ class TMDO_Logger {
/**
* 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
* many times per request — keeping them out of the DB error table.
*
@@ -57,19 +57,36 @@ class TMDO_Logger {
* @return 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 ) : '' ) );
}
/**
* Log a NOTICE-level event — alias of info() for callers that want a stronger
* level intent. Same routing (error_log only).
* Log a NOTICE-level event: always written to the PHP error log (unlike
* info()), for the audit trail of operator actions such as mode changes
* and snapshot deletions.
*
* @param string $event Event name.
* @param array $context Structured context.
* @param array<string, mixed> $context Structured context.
* @return 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 {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
self::info( $event, $context );
self::write_info( $event, $context );
}
}
+2 -18
View File
@@ -55,7 +55,7 @@ final class TMDO_Post_Shadow_Verifier {
* is available.
*
* @param string $post_type WP post_type to sample.
* @param string $group_name Entity group name (e.g. 'wc_product').
* @param string $group_name Entity group name (e.g. 'hp_listing_core').
* @param string $flat_table Fully qualified flat table name.
* @param string[] $keys Meta keys to compare (each post checks all).
* @param int $sample_size Number of posts to sample (capped to DB count).
@@ -303,23 +303,7 @@ final class TMDO_Post_Shadow_Verifier {
* @return string|null
*/
private static function group_post_type( string $group ): ?string {
switch ( $group ) {
case 'attachment':
return 'attachment';
case 'wc_product':
return 'product';
case 'hp_listing_core':
return 'hp_listing';
case 'hp_request_core':
return 'hp_request';
case 'hp_vendor_core':
return 'hp_vendor';
case 'nav_menu_item':
return 'nav_menu_item';
case 'wp_core':
default:
return null;
}
return TMDO_Post_Fields::post_type_for_group( $group );
}
/**
@@ -872,7 +872,6 @@ final class TMDO_Post_Stress_Tester {
global $wpdb;
$prefix = $wpdb->prefix . 'wpdo_post_';
$map = array(
'product' => $prefix . 'wc_product',
'hp_listing' => $prefix . 'hp_listing_core',
'hp_request' => $prefix . 'hp_request_core',
'hp_vendor' => $prefix . 'hp_vendor_core',
@@ -1036,7 +1035,6 @@ final class TMDO_Post_Stress_Tester {
return array(
$prefix . 'wp_core',
$prefix . 'attachment',
$prefix . 'wc_product',
$prefix . 'hp_listing_core',
$prefix . 'hp_request_core',
$prefix . 'hp_vendor_core',
+60 -18
View File
@@ -23,6 +23,9 @@ if ( ! defined( 'ABSPATH' ) ) {
*/
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';
/**
@@ -37,6 +40,8 @@ class TMDO_REST_API {
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_listings' ),
// Public by design: viewable post types and published posts only, fields
// declared show_in_rest only (1.1.1), per_page capped (wpdo_rest_max_per_page).
'permission_callback' => '__return_true',
'args' => $this->listings_args(),
)
@@ -48,6 +53,8 @@ class TMDO_REST_API {
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_listing' ),
// Public by design: the post must be viewable to the request (assert_post_viewable_or_error:
// publicly viewable or read_post); fields declared show_in_rest only (1.1.1).
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
@@ -65,6 +72,7 @@ class TMDO_REST_API {
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_stats' ),
// Public by design: view / engagement counters of a post viewable to the request.
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
@@ -82,6 +90,8 @@ class TMDO_REST_API {
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'post_view' ),
// Public by design: anonymous visitors count views; viewable posts only, one view
// per IP per post per hour (rate limited).
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
@@ -529,7 +539,16 @@ class TMDO_REST_API {
* @return WP_REST_Response REST response.
*/
public function get_listings( WP_REST_Request $request ): WP_REST_Response {
$post_type = $request->get_param( 'post_type' ) ?? 'hp_listing';
$post_type = $request->get_param( 'post_type' ) ?? self::default_post_type();
if ( ! post_type_exists( $post_type ) || ! is_post_type_viewable( $post_type ) ) {
return new WP_REST_Response(
array(
'code' => 'rest_invalid_post_type',
'message' => 'Invalid post type.',
),
400
);
}
// Defense-in-depth: clamp per_page even if validate_callback in listings_args() fails open.
// Filter `wpdo_rest_max_per_page` allows site owners to adjust the upper bound.
$max_per_page = (int) apply_filters( 'wpdo_rest_max_per_page', 100 );
@@ -709,14 +728,18 @@ class TMDO_REST_API {
if ( $cookie_blocked || $ip_blocked ) {
// Track rate-limit hits per post for admin visibility, capped at 100 entries.
// Sampled (1 in RL_STATS_SAMPLE, each sample adding RL_STATS_SAMPLE) so
// anonymous requests cannot turn every blocked view into an option write.
if ( 1 === wp_rand( 1, self::RL_STATS_SAMPLE ) ) {
$rl_stats = get_option( 'wpdo_rl_stats', array() );
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1;
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + self::RL_STATS_SAMPLE;
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 );
}
return new WP_REST_Response(
array(
@@ -1365,6 +1388,10 @@ class TMDO_REST_API {
/**
* Query Zone A flat table directly.
*
* Rows are joined to the posts table so only published posts of the
* requested type are listed (the hot table also holds drafts, private
* and trashed posts).
*
* @param string $post_type Post type slug.
* @param int $per_page Number of items per page.
* @param int $offset Database offset.
@@ -1383,8 +1410,13 @@ class TMDO_REST_API {
): WP_REST_Response {
global $wpdb;
// Filters and ordering only on public columns: hidden values must not be
// inferable by filtering or sorting on them.
$table = TMDO_Zone_Hot::table( $post_type );
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
$columns = array_intersect_key(
TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ),
array_flip( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) )
);
// Build WHERE clauses from numeric filter params.
$where = array();
@@ -1396,6 +1428,8 @@ class TMDO_REST_API {
}
$where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : '';
$join_sql = "INNER JOIN `{$wpdb->posts}` p ON p.ID = t.post_id AND p.post_type = %s AND p.post_status = 'publish'";
array_unshift( $params, $post_type );
// Validate orderby against known columns + post_id.
$allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) );
@@ -1404,16 +1438,12 @@ class TMDO_REST_API {
// Count total.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table from TMDO_Zone_Hot::table(); where_sql/orderby_col/order_dir are sanitized/validated.
if ( $params ) {
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` {$where_sql}", ...$params ) );
} else {
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
}
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` t {$join_sql} {$where_sql}", ...$params ) );
// Fetch rows.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM `{$table}` {$where_sql} ORDER BY `{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d",
"SELECT t.* FROM `{$table}` t {$join_sql} {$where_sql} ORDER BY t.`{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d",
...array_merge( $params, array( $per_page, $offset ) )
),
ARRAY_A
@@ -1432,8 +1462,8 @@ class TMDO_REST_API {
'post_type' => $post_type,
);
unset( $row['post_id'], $row['updated_at'] );
// Keep only columns with show_in_rest => true (or all if no registry entry).
$row = $visible_cols ? array_intersect_key( $row, $visible_cols ) : $row;
// Keep only public columns (none when nothing is declared public).
$row = array_intersect_key( $row, $visible_cols );
return array_merge( $item, $row );
},
$rows
@@ -1476,8 +1506,8 @@ class TMDO_REST_API {
'id' => $post->ID,
'post_type' => $post->post_type,
);
$cols = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
foreach ( array_keys( $cols ) as $col ) {
// Same show_in_rest restriction as the Zone A path.
foreach ( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) as $col ) {
$item[ $col ] = get_post_meta( $post->ID, $col, true );
}
$items[] = $item;
@@ -1507,7 +1537,7 @@ class TMDO_REST_API {
$min = $request->get_param( $col . '_min' );
if ( null !== $min && is_numeric( $min ) ) {
$filters[] = array(
'sql' => "`{$col}` >= %f",
'sql' => "t.`{$col}` >= %f",
'value' => (float) $min,
);
}
@@ -1515,7 +1545,7 @@ class TMDO_REST_API {
$max = $request->get_param( $col . '_max' );
if ( null !== $max && is_numeric( $max ) ) {
$filters[] = array(
'sql' => "`{$col}` <= %f",
'sql' => "t.`{$col}` <= %f",
'value' => (float) $max,
);
}
@@ -1524,12 +1554,12 @@ class TMDO_REST_API {
if ( null !== $exact && '' !== $exact ) {
if ( is_numeric( $exact ) ) {
$filters[] = array(
'sql' => "`{$col}` = %f",
'sql' => "t.`{$col}` = %f",
'value' => (float) $exact,
);
} else {
$filters[] = array(
'sql' => "`{$col}` = %s",
'sql' => "t.`{$col}` = %s",
'value' => (string) $exact,
);
}
@@ -1539,13 +1569,25 @@ class TMDO_REST_API {
return $filters;
}
/**
* Post type GET /listings serves when the request names none.
*
* Filter `tmdo_rest_default_post_type` lets an integration (e.g. the
* HivePress AddOn) make its own post type the default.
*
* @return string
*/
public static function default_post_type(): string {
return (string) apply_filters( 'tmdo_rest_default_post_type', 'post' );
}
/**
* Argument definitions for GET /listings.
*/
private function listings_args(): array {
return array(
'post_type' => array(
'default' => 'hp_listing',
'default' => self::default_post_type(),
'sanitize_callback' => 'sanitize_key',
),
'per_page' => array(
+56 -4
View File
@@ -67,6 +67,13 @@ class TMDO_Schema_Registry {
*/
private array $warm_fields = array();
/**
* Providers already told the Hot zone is frozen (once per request).
*
* @var array<string, true>
*/
private array $hot_deprecation_flagged = array();
/**
* Private constructor — use instance() to get the singleton.
*/
@@ -96,7 +103,9 @@ class TMDO_Schema_Registry {
* - ttl (int) TTL in seconds (Zone B only, null = no expiry).
* - cache_group (string) Object cache group (Zone C only).
* - cache_ttl (int) Cache TTL in seconds (Zone C only).
* - show_in_rest (bool) Whether to expose via GET /wpdo/v1/listings. Default true.
* - show_in_rest (bool) Whether to expose via the public GET /wpdo/v1/listings
* endpoints. Default false; protected (underscore) keys and
* credential-like keys are never exposed (see rest_exposable()).
*/
public function register( string $provider, array $config ): void {
$config = wp_parse_args(
@@ -113,7 +122,7 @@ class TMDO_Schema_Registry {
'cache_group' => '',
'cache_ttl' => HOUR_IN_SECONDS,
'provider' => $provider,
'show_in_rest' => true,
'show_in_rest' => false,
)
);
@@ -138,6 +147,7 @@ class TMDO_Schema_Registry {
switch ( $config['zone'] ) {
case 'hot':
$this->hot_columns[ $config['post_type'] ][ $config['column'] ] = $config['data_type'];
$this->flag_hot_deprecation( $provider, $config['meta_key'] );
break;
case 'warm':
@@ -159,6 +169,29 @@ class TMDO_Schema_Registry {
}
}
/**
* The Hot zone is frozen (ADR-003): fields still register so existing hot
* data stays readable, but each provider gets one notice per request.
*
* @param string $provider Provider name.
* @param string $meta_key First hot meta_key seen from it.
*/
private function flag_hot_deprecation( string $provider, string $meta_key ): void {
if ( isset( $this->hot_deprecation_flagged[ $provider ] ) ) {
return;
}
$this->hot_deprecation_flagged[ $provider ] = true;
_doing_it_wrong(
__CLASS__ . '::register',
sprintf(
'Provider "%1$s" registered Hot zone field "%2$s". The Hot zone is frozen; register an entity group (TMDO_Entity_Registry::register_group) instead.',
esc_html( $provider ),
esc_html( $meta_key )
),
esc_html( TMDO_VERSION )
);
}
/**
* Bulk register multiple fields.
*
@@ -245,6 +278,25 @@ class TMDO_Schema_Registry {
return array_unique( $this->cold_fields[ $post_type ] ?? array() );
}
/**
* Whether a field may appear in the public listings endpoints: declared
* show_in_rest, not a protected meta key (leading underscore — WordPress
* hides those from public output, e.g. _purchase_note), and not named like a
* credential (tokens, secrets, passwords, API keys) 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.
* Used by REST endpoints to restrict publicly exposed fields.
@@ -255,7 +307,7 @@ class TMDO_Schema_Registry {
public function get_rest_visible_hot_columns( string $post_type ): array {
$visible = array();
foreach ( $this->fields as $field ) {
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
$visible[] = $field['column'];
}
}
@@ -272,7 +324,7 @@ class TMDO_Schema_Registry {
public function get_rest_visible_cold_keys( string $post_type ): array {
$visible = array();
foreach ( $this->fields as $field ) {
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
$visible[] = $field['meta_key'];
}
}
+18 -16
View File
@@ -60,20 +60,22 @@ if ( ! defined( 'ABSPATH' ) ) {
final class TMDO_Term_Comment_Backfill {
/**
* Group → flat table suffix mapping. Mirror of TMDO_Hivepress_Term_Comment_Fields.
* Term and comment groups registered in the entity registry.
*
* @var array<string, array{entity_type:string, table_suffix:string}>
* @return array<string, string> Group name → entity type ('term' | 'comment').
*/
private const GROUP_MAP = array(
'hp_taxonomy' => array(
'entity_type' => 'term',
'table_suffix' => 'wpdo_term_hp_taxonomy',
),
'hp_review' => array(
'entity_type' => 'comment',
'table_suffix' => 'wpdo_comment_hp_review',
),
);
public static function registered_groups(): array {
$groups = array();
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return $groups;
}
foreach ( array( 'term', 'comment' ) as $entity_type ) {
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
$groups[ $group ] = $groups[ $group ] ?? $entity_type;
}
}
return $groups;
}
/**
* Run backfill for every registered term + comment group.
@@ -83,8 +85,8 @@ final class TMDO_Term_Comment_Backfill {
*/
public static function backfill_all( bool $dry_run = false ): array {
$results = array();
foreach ( self::GROUP_MAP as $group_name => $cfg ) {
$results[ $group_name ] = self::backfill_group( $cfg['entity_type'], $group_name, $dry_run );
foreach ( self::registered_groups() as $group_name => $entity_type ) {
$results[ $group_name ] = self::backfill_group( $entity_type, $group_name, $dry_run );
}
return $results;
}
@@ -110,7 +112,7 @@ final class TMDO_Term_Comment_Backfill {
$result['error'] = 'Entity Registry unavailable';
return $result;
}
if ( ! isset( self::GROUP_MAP[ $group_name ] ) ) {
if ( ( self::registered_groups()[ $group_name ] ?? null ) !== $entity_type ) {
$result['error'] = "Unknown group: {$group_name}";
return $result;
}
@@ -140,7 +142,7 @@ final class TMDO_Term_Comment_Backfill {
return $result;
}
$flat_table = $wpdb->prefix . self::GROUP_MAP[ $group_name ]['table_suffix'];
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group_name );
// Count candidate entities — those with at least one of the registered keys in wp_*meta.
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
@@ -41,23 +41,6 @@ final class TMDO_Term_Comment_Shadow_Verifier {
/** Default sample size per entity per cron tick. */
public const DEFAULT_SAMPLE_SIZE = 100;
/**
* Group → flat table suffix mapping. Keep in sync with
* TMDO_Hivepress_Term_Comment_Fields registrations and the misc bucket.
*
* @var array<string, array{entity_type:string, table_suffix:string, has_misc:bool}>
*/
private const GROUP_MAP = array(
'hp_taxonomy' => array(
'entity_type' => 'term',
'table_suffix' => 'wpdo_term_hp_taxonomy',
),
'hp_review' => array(
'entity_type' => 'comment',
'table_suffix' => 'wpdo_comment_hp_review',
),
);
/**
* Run a sample-compare pass for a single entity group.
*
@@ -215,11 +198,7 @@ final class TMDO_Term_Comment_Shadow_Verifier {
return;
}
global $wpdb;
foreach ( self::GROUP_MAP as $group => $cfg ) {
$entity_type = $cfg['entity_type'];
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $entity_type ) {
// Only run when the entity is in shadow_read mode.
if ( 'term' === $entity_type && ! $term_shadow ) {
continue;
@@ -232,7 +211,7 @@ final class TMDO_Term_Comment_Shadow_Verifier {
if ( empty( $keys ) ) {
continue;
}
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
try {
self::sample_compare( $entity_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
@@ -337,16 +316,14 @@ final class TMDO_Term_Comment_Shadow_Verifier {
return array();
}
global $wpdb;
$out = array();
foreach ( self::GROUP_MAP as $group => $cfg ) {
$entity_type = $cfg['entity_type'];
foreach ( TMDO_Term_Comment_Backfill::registered_groups() as $group => $entity_type ) {
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
if ( empty( $keys ) ) {
continue;
}
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
$flat_table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
try {
$out[ $group ] = self::sample_compare( $entity_type, $group, $flat_table, $keys, $sample_size );
@@ -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 ) {
// 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() );
}
}
+123 -2
View File
@@ -2,7 +2,7 @@
/**
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
*
* Registers 7 tests under Tools → Site Health → Status:
* Registers 10 tests under Tools → Site Health → Status:
* 1. wpdo_schema_drift (critical)
* 2. wpdo_error_budget (recommended)
* 3. wpdo_hook_conflicts (recommended)
@@ -10,6 +10,9 @@
* 5. wpdo_postmeta_explosion (recommended)
* 6. wpdo_orphan_zone_rows (recommended)
* 7. wpdo_missing_snapshot (critical)
* 8. wpdo_entity_group_collisions (recommended)
* 9. wpdo_orphaned_entity_groups (recommended)
* 10. wpdo_missing_integration_addons (recommended)
*
* Each test result is cached for 5 minutes to keep Site Health responsive.
*
@@ -41,6 +44,9 @@ class TMDO_Site_Health {
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
'wpdo_missing_snapshot' => 'check_missing_snapshot',
'wpdo_entity_group_collisions' => 'check_entity_group_collisions',
'wpdo_orphaned_entity_groups' => 'check_orphaned_entity_groups',
'wpdo_missing_integration_addons' => 'check_missing_integration_addons',
);
/**
@@ -53,7 +59,7 @@ class TMDO_Site_Health {
}
/**
* Register the 7 tests with WP Site Health.
* Register the 10 tests with WP Site Health.
*
* @param array $tests Existing tests.
* @return array
@@ -83,6 +89,9 @@ class TMDO_Site_Health {
'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ),
'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ),
'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ),
'wpdo_entity_group_collisions' => __( 'WPDO entity group collisions', '2meet-data-optimizer' ),
'wpdo_orphaned_entity_groups' => __( 'WPDO orphaned entity groups', '2meet-data-optimizer' ),
'wpdo_missing_integration_addons' => __( 'WPDO integrations without their AddOn', '2meet-data-optimizer' ),
);
return $map[ $key ] ?? $key;
}
@@ -425,6 +434,118 @@ class TMDO_Site_Health {
// ─── helpers ──────────────────────────────────────────────────────────
// ─── Test 8: Entity group name collisions ───────────────────────────
/**
* Report entity groups registered twice with different definitions.
*
* Not cached: the collision list is in-memory state rebuilt on every
* request, so reading it is free and a transient would only go stale.
*
* @return array<string, mixed> Site Health result.
*/
public static function check_entity_group_collisions(): array {
$collisions = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_collisions() : array();
if ( empty( $collisions ) ) {
return self::wrap( 'wpdo_entity_group_collisions', self::pass( __( 'No entity group name collisions.', '2meet-data-optimizer' ) ) );
}
$lines = array();
foreach ( $collisions as $c ) {
$lines[] = sprintf(
'%s/%s (+%s −%s ~%s)',
$c['entity_type'],
$c['group'],
implode( ',', $c['added'] ) ?: '-',
implode( ',', $c['removed'] ) ?: '-',
implode( ',', $c['changed'] ) ?: '-'
);
}
return self::wrap(
'wpdo_entity_group_collisions',
self::fail(
__( 'WPDO entity group name collisions', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: list of entity/group names with field differences */
__( 'Another plugin registered these groups with a different field list; its definition was ignored and the first one kept: %s', '2meet-data-optimizer' ),
implode( '; ', $lines )
),
'recommended'
)
);
}
// ─── Test 9: Orphaned entity groups ─────────────────────────────────
/**
* Report flat-table groups kept alive from wpdo_registry_meta because no
* active plugin registers them any more.
*
* Not cached for the same reason as check_entity_group_collisions().
*
* @return array<string, mixed> Site Health result.
*/
public static function check_orphaned_entity_groups(): array {
$restored = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_restored_groups() : array();
if ( empty( $restored ) ) {
return self::wrap( 'wpdo_orphaned_entity_groups', self::pass( __( 'Every flat-table group is registered by an active plugin.', '2meet-data-optimizer' ) ) );
}
$lines = array();
foreach ( $restored as $r ) {
$lines[] = sprintf(
'%s/%s (%d fields%s%s)',
$r['entity_type'],
$r['group'],
$r['fields'],
$r['skipped'] ? ', skipped: ' . implode( ',', $r['skipped'] ) : '',
$r['native'] ? ', left on native meta (newer data there): ' . implode( ',', $r['native'] ) : ''
);
}
return self::wrap(
'wpdo_orphaned_entity_groups',
self::fail(
__( 'WPDO entity groups restored from stored definitions', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: list of entity/group names */
__( 'No active plugin registers these groups, so their stored definitions were re-applied to keep the flat-table data readable. Reactivate the owning plugin, or write the data back to native meta before removing it: %s', '2meet-data-optimizer' ),
implode( '; ', $lines )
),
'recommended'
)
);
}
// ─── Test 10: Integrations without their AddOn ──────────────────────
/**
* Report HivePress / WooCommerce running while their AddOn is inactive.
*
* Not cached: two class_exists() probes.
*
* @return array<string, mixed> Site Health result.
*/
public static function check_missing_integration_addons(): array {
$missing = class_exists( 'TMDO_Compatibility' ) ? TMDO_Compatibility::missing_integration_addons() : array();
if ( empty( $missing ) ) {
return self::wrap( 'wpdo_missing_integration_addons', self::pass( __( 'Every detected integration has its AddOn active.', '2meet-data-optimizer' ) ) );
}
$lines = array();
foreach ( $missing as $plugin => $addon ) {
$lines[] = "{$plugin} → {$addon}";
}
return self::wrap(
'wpdo_missing_integration_addons',
self::fail(
__( 'WPDO integrations running without their AddOn', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: "Plugin → AddOn" pairs */
__( 'These plugins are active but their AddOn is not, so their fields are not optimised: %s. Activate the AddOn.', '2meet-data-optimizer' ),
implode( '; ', $lines )
),
'recommended'
)
);
}
/**
* Wrap a raw check result with required Site Health envelope fields.
*
+11 -8
View File
@@ -6,7 +6,8 @@
* 判斷條件(皆滿足才觸發):
* 1. 當前 mode = shadow_read
* 2. 進入 shadow_read 距今 ≥ `min_days`
* 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0
* 3. 該 entity 每個群組最近一次 drift-check(2 天內,抽樣或全量)皆無漂移
* (1.2.0 起取代 shadow_diffs 筆數;無紀錄不升級)
*
* 這符合「穩定期沒問題就進 production」的安全遷移原則。
* 若 diff 出現,規則永不觸發;人工介入即可。
@@ -35,6 +36,9 @@ final class TMDO_Auto_Promoter {
public const CRON_HOOK = 'wpdo_auto_promote_check';
/** A drift check older than this (seconds) does not count for promotion. */
public const DRIFT_MAX_AGE = 2 * DAY_IN_SECONDS;
public static function init(): void {
add_action( self::CRON_HOOK, array( self::class, 'check_all' ) );
add_action( 'init', array( self::class, 'maybe_schedule' ), 30 );
@@ -82,7 +86,7 @@ final class TMDO_Auto_Promoter {
/**
* 評估單一 entity 是否可升級。
*
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'has_diff' | 'vetoed'
* @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'drift_not_clean' | 'vetoed' | 'error'
*/
public static function check_entity( string $type, int $min_days, array $entered_all ): string {
$current = TMDO_Mode_Manager::get( $type );
@@ -95,14 +99,13 @@ final class TMDO_Auto_Promoter {
return 'not_ready';
}
$diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity();
$count = (int) ( $diff_counts[ $type ] ?? 0 );
if ( $count > 0 ) {
return 'has_diff';
if ( ! TMDO_Drift_Check::clean_since( $type, self::DRIFT_MAX_AGE ) ) {
return 'drift_not_clean';
}
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count );
// 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)。第 4 參數原為
// shadow diff 筆數;1.2.0 起改看 drift,固定傳 0。
$should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, 0 );
if ( ! $should ) {
return 'vetoed';
}
+14 -1
View File
@@ -325,7 +325,20 @@ final class TMDO_Entity_Health {
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 {
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 ) {
++$stats['errors'];
error_log(
sprintf(
'[UAE Migration] Error %s/%s ID=%d: %s',
$entity_type,
$group_name,
$entity_id,
$e->getMessage()
TMDO_Logger::error(
'migration_engine',
'migrate_group',
$e->getMessage(),
array(
'entity_type' => $entity_type,
'group' => $group_name,
'entity_id' => $entity_id,
)
);
}
@@ -175,6 +176,9 @@ final class TMDO_Entity_Migration_Engine {
// 標記完成
if ( ! $options['dry_run'] ) {
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 );
@@ -182,6 +186,45 @@ final class TMDO_Entity_Migration_Engine {
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
*/
@@ -243,6 +286,27 @@ final class TMDO_Entity_Migration_Engine {
return false;
}
// REPLACE rewrites the whole row: a column left out falls back to its DDL
// default (e.g. session_count DEFAULT 0), inventing a value the object never
// had. Keys without a native row are written as NULL; required columns are
// NOT NULL, so they keep their default (treated as "absent" by the diff logger).
foreach ( $field_map as $key => $field_def ) {
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
if ( ! array_key_exists( $col, $data ) && empty( $field_def['required'] ) ) {
$data[ $col ] = null;
$formats[] = '%s';
}
}
// 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 ) {
return true;
}
@@ -349,13 +413,25 @@ final class TMDO_Entity_Migration_Engine {
++$batch_migrated;
}
} catch ( \Throwable $e ) {
error_log( sprintf( '[UAE Migration Batch] Error %s/%s ID=%d: %s', $entity_type, $group_name, $entity_id, $e->getMessage() ) );
TMDO_Logger::error(
'migration_engine',
'migrate_group_batch',
$e->getMessage(),
array(
'entity_type' => $entity_type,
'group' => $group_name,
'entity_id' => $entity_id,
)
);
}
$last_id = $entity_id;
}
$total = $prev_total + $batch_migrated;
$done = count( $entity_ids ) < $batch_size;
if ( $batch_migrated > 0 ) {
self::counts_changed( $entity_type );
}
if ( $done ) {
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 ) );
}
// ─────────────────────────────────────────────────────────
// 斷點管理
// ─────────────────────────────────────────────────────────
+243 -14
View File
@@ -31,6 +31,24 @@ final class TMDO_Entity_Registry {
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
private static array $pending_schemas = array();
/** @var array<string, array<string, array<string, mixed>>> 實體類型 → meta_key → 欄位定義(僅未限定 post_type 的群組)*/
private static array $unscoped_index = array();
/** @var array<string, array<string, array<string, array<string, mixed>>>> 實體類型 → post_type → meta_key → 欄位定義 */
private static array $scoped_index = array();
/** @var array<string, array<string, string[]>> 實體類型 → 群組名 → 限定的 post_type */
private static array $scopes = array();
/** @var array<string, array<string, array<string, string[]>>> 實體類型 → 群組名 → 索引名 → 欄位 key */
private static array $indexes = array();
/** @var array<string, array<string, mixed>> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
private static array $collisions = array();
/** @var array<string, array<string, mixed>> "entity/group" → 從 wpdo_registry_meta 還原的孤兒群組 */
private static array $restored = array();
/** 合法的欄位型別 */
public const VALID_TYPES = array(
'text',
@@ -49,11 +67,20 @@ final class TMDO_Entity_Registry {
/** 合法的實體類型 */
public const VALID_ENTITY_TYPES = array( 'post', 'user', 'term', 'comment' );
/** Option:'1' 時 get_field() 依傳入的 post_type 解析(見 get_field())。 */
public const SCOPED_ROUTING_OPTION = 'tmdo_post_scoped_routing';
public static function init(): void {
self::$adapters = array();
self::$groups = array();
self::$field_index = array();
self::$unscoped_index = array();
self::$scoped_index = array();
self::$scopes = array();
self::$indexes = array();
self::$pending_schemas = array();
self::$collisions = array();
self::$restored = array();
}
// ─────────────────────────────────────────────────────────
@@ -92,10 +119,17 @@ final class TMDO_Entity_Registry {
* - unique (bool) 唯一索引
* - options (array) enum 選項
* - label (string) 顯示名稱
* @param array<string, mixed> $args 選填:post_types (string[]) — 群組只套用於這些 post_type。
* 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬;
* 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。
* 不列入欄位定義,所以不影響 schema hash 與衝突比對。
* indexes (array<string, string[]>) — 複合索引:名稱 → 欄位 key(依序);
* 含未知欄位的索引會被略過。同樣不影響 schema hash,
* 由 TMDO_Schema_Manager::ensure_group_indexes() 補建到既有表。
*
* @return bool
*/
public static function register_group( string $entity_type, string $group_name, array $fields ): bool {
public static function register_group( string $entity_type, string $group_name, array $fields, array $args = array() ): bool {
if ( ! in_array( $entity_type, self::VALID_ENTITY_TYPES, true ) ) {
return false;
@@ -105,12 +139,57 @@ final class TMDO_Entity_Registry {
return false;
}
// 防重複登錄
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
$normalized = self::normalize_fields( $entity_type, $group_name, $fields );
if ( empty( $normalized ) ) {
return false;
}
// 驗證並正規化欄位定義
// 防重複登錄:相同定義(init:1 重新觸發)靜默略過;定義不同則記錄衝突,保留先註冊者。
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
if ( self::$groups[ $entity_type ][ $group_name ] !== $normalized ) {
self::record_collision( $entity_type, $group_name, self::$groups[ $entity_type ][ $group_name ], $normalized );
}
return false;
}
// 建立索引以供快速查詢
$post_types = array_values( array_filter( array_map( 'strval', (array) ( $args['post_types'] ?? array() ) ) ) );
if ( $post_types ) {
self::$scopes[ $entity_type ][ $group_name ] = $post_types;
}
$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 ) {
$columns = array_values( array_map( 'strval', (array) $columns ) );
$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;
}
}
foreach ( $normalized as $normalized_field ) {
self::index_field( $entity_type, $normalized_field, $post_types );
}
self::$groups[ $entity_type ][ $group_name ] = $normalized;
// 加入待建表佇列
self::$pending_schemas[] = array(
'type' => $entity_type,
'group' => $group_name,
'fields' => $normalized,
);
return true;
}
/**
* 驗證並正規化欄位定義(略過缺 key/type 或型別不合法者)。
* @param array<int, array<string, mixed>> $fields
* @return array<int, array<string, mixed>>
*/
private static function normalize_fields( string $entity_type, string $group_name, array $fields ): array {
$normalized = array();
foreach ( $fields as $field ) {
@@ -141,27 +220,148 @@ final class TMDO_Entity_Registry {
$normalized_field['entity_type'] = $entity_type;
$normalized[] = $normalized_field;
// 建立索引以供快速查詢
self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field;
}
if ( empty( $normalized ) ) {
return $normalized;
}
/**
* 還原孤兒群組:依 wpdo_registry_meta 保存的定義重新登錄,只供讀寫路由用。
*
* 與 register_group() 的差異:不進 pending_schemas(絕不觸發 DDL),且已被
* 其他存活群組管理的 key 一律略過,不覆蓋。$native_keys 列出原生 meta 仍有
* 資料的 key:那些值比扁平表新(孤兒期間寫入),必須留在原生 meta。
*
* @param string[] $native_keys 原生 meta 仍有資料、不可還原的 key。
* @return bool 有登錄任何欄位時為 true
* @param array<int, array<string, mixed>> $fields
*/
public static function restore_group( string $entity_type, string $group_name, array $fields, array $native_keys = array() ): bool {
if ( ! isset( self::$adapters[ $entity_type ] ) || isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
return false;
}
self::$groups[ $entity_type ][ $group_name ] = $normalized;
$kept = array();
$skipped = array();
$native = array();
foreach ( self::normalize_fields( $entity_type, $group_name, $fields ) as $field ) {
if ( isset( self::$field_index[ $entity_type ][ $field['key'] ] ) ) {
$skipped[] = (string) $field['key'];
continue;
}
if ( in_array( $field['key'], $native_keys, true ) ) {
$native[] = (string) $field['key'];
continue;
}
$kept[] = $field;
}
// 加入待建表佇列
self::$pending_schemas[] = array(
'type' => $entity_type,
$id = $entity_type . '/' . $group_name;
if ( empty( $kept ) ) {
// 整組都留在原生 meta 時仍要回報:扁平表的舊資料需要人工處理。
if ( $native ) {
self::$restored[ $id ] = array(
'entity_type' => $entity_type,
'group' => $group_name,
'fields' => $normalized,
'fields' => 0,
'skipped' => $skipped,
'native' => $native,
);
}
return false;
}
foreach ( $kept as $field ) {
self::index_field( $entity_type, $field, array() );
}
self::$groups[ $entity_type ][ $group_name ] = $kept;
self::$restored[ $id ] = array(
'entity_type' => $entity_type,
'group' => $group_name,
'fields' => count( $kept ),
'skipped' => $skipped,
'native' => $native,
);
return true;
}
/**
* 把欄位加入索引。field_index 保留舊語意(整個實體共用、後註冊者覆蓋),
* 供不帶 post_type 的查詢使用;另外依是否限定 post_type 分別建索引。
*
* @param string[] $post_types 群組限定的 post_type;空陣列表示不限定。
* @param array<string, mixed> $field
*/
private static function index_field( string $entity_type, array $field, array $post_types ): void {
self::$field_index[ $entity_type ][ $field['key'] ] = $field;
if ( empty( $post_types ) ) {
self::$unscoped_index[ $entity_type ][ $field['key'] ] = $field;
return;
}
foreach ( $post_types as $post_type ) {
self::$scoped_index[ $entity_type ][ $post_type ][ $field['key'] ] = $field;
}
}
/**
* 本請求從 wpdo_registry_meta 還原的孤兒群組。
*
* @return array<int, array{entity_type:string, group:string, fields:int, skipped:string[], native:string[]}>
*/
public static function get_restored_groups(): array {
return array_values( self::$restored );
}
/**
* 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。
*
* 註冊每個請求都會跑,warning 以 transient 節流為每天一次,避免灌爆 wpdo_errors。
* @param array<int, array<string, mixed>> $existing
* @param array<int, array<string, mixed>> $incoming
*/
private static function record_collision( string $entity_type, string $group_name, array $existing, array $incoming ): void {
$id = $entity_type . '/' . $group_name;
if ( isset( self::$collisions[ $id ] ) ) {
return;
}
$old = array_column( $existing, null, 'key' );
$new = array_column( $incoming, null, 'key' );
$changed = array();
foreach ( array_intersect_key( $new, $old ) as $key => $field ) {
if ( $field !== $old[ $key ] ) {
$changed[] = (string) $key;
}
}
self::$collisions[ $id ] = array(
'entity_type' => $entity_type,
'group' => $group_name,
'added' => array_map( 'strval', array_keys( array_diff_key( $new, $old ) ) ),
'removed' => array_map( 'strval', array_keys( array_diff_key( $old, $new ) ) ),
'changed' => $changed,
);
$throttle = 'tmdo_group_collision_' . md5( (string) wp_json_encode( self::$collisions[ $id ] ) );
if ( class_exists( 'TMDO_Logger' ) && false === get_transient( $throttle ) ) {
set_transient( $throttle, 1, DAY_IN_SECONDS );
TMDO_Logger::warning( 'entity_group_collision', self::$collisions[ $id ] );
}
}
/**
* 本請求偵測到的同名群組衝突(第二份定義已被捨棄)。
*
* @return array<int, array{entity_type:string, group:string, added:string[], removed:string[], changed:string[]}>
*/
public static function get_collisions(): array {
return array_values( self::$collisions );
}
// ─────────────────────────────────────────────────────────
// 查詢 API
// ─────────────────────────────────────────────────────────
@@ -169,11 +369,22 @@ final class TMDO_Entity_Registry {
/**
* 依 meta_key 查詢欄位定義
*
* 傳入 $post_type 且 SCOPED_ROUTING_OPTION 開啟時:先找限定該 post_type 的群組,
* 再找不限定的群組,都沒有則為 null。否則沿用舊語意(整個實體共用索引,
* 共用 key 由最後註冊的群組擁有)。舊資料可能已依舊語意散落在別的群組表,
* 所以既有站要等資料對齊後才開啟 option。
*
* @param string $post_type 選填:post 的 post_type。
* @return array|null 欄位定義,或 null(非 UAE 管理欄位)
*/
public static function get_field( string $entity_type, string $meta_key ): ?array {
public static function get_field( string $entity_type, string $meta_key, string $post_type = '' ): ?array {
if ( '' === $post_type || '1' !== (string) get_option( self::SCOPED_ROUTING_OPTION, '0' ) ) {
return self::$field_index[ $entity_type ][ $meta_key ] ?? null;
}
return self::$scoped_index[ $entity_type ][ $post_type ][ $meta_key ]
?? self::$unscoped_index[ $entity_type ][ $meta_key ]
?? null;
}
/**
* 取得實體類型下所有群組名稱
@@ -184,6 +395,24 @@ final class TMDO_Entity_Registry {
return array_keys( self::$groups[ $entity_type ] ?? array() );
}
/**
* 群組宣告的複合索引。
*
* @return array<string, string[]> 索引名 → 欄位 key
*/
public static function get_group_indexes( string $entity_type, string $group_name ): array {
return self::$indexes[ $entity_type ][ $group_name ] ?? array();
}
/**
* 群組限定的 post_type(未限定時為空陣列)。
*
* @return string[]
*/
public static function get_group_post_types( string $entity_type, string $group_name ): array {
return self::$scopes[ $entity_type ][ $group_name ] ?? array();
}
/**
* 取得特定群組的完整欄位定義
*/
+404 -54
View File
@@ -44,6 +44,15 @@ final class TMDO_Hook_Bus {
// ── 刪除攔截 ──────────────────────────────────────────
add_filter( "delete_{$type}_metadata", array( self::class, 'intercept_delete' ), 10, 5 );
// ── dual_write / shadow_read:原生寫入完成後,flat 從原生同步 ──
// Native is the source of truth in these modes; flat follows what
// native actually became (WP may skip or refuse a write).
add_action( "added_{$type}_meta", array( self::class, 'on_meta_added' ), 1, 4 );
add_action( "updated_{$type}_meta", array( self::class, 'on_meta_updated' ), 1, 4 );
add_action( "delete_{$type}_meta", array( self::class, 'before_meta_deleted' ), 1, 4 );
add_action( "deleted_{$type}_meta", array( self::class, 'on_meta_deleted' ), 1, 4 );
add_filter( "update_{$type}_metadata_by_mid", array( self::class, 'before_update_by_mid' ), 1, 4 );
// ── 實體刪除時自動清理 ───────────────────────────────
add_action(
$adapter->get_delete_hook(),
@@ -70,25 +79,25 @@ final class TMDO_Hook_Bus {
*/
public static function intercept_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
return $check;
}
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
return $check;
}
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
if ( ! $field_def ) {
return $check; // 非管理欄位,放行
}
// ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全放行給 WP 原生 meta(回 null / $check)
// dual_write: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
// shadow_read: 寫 flat,然後 return null 讓 WP 繼續寫 EAV
// dual_write : 放行 WP 寫 EAV;updated_/added_ action 再從原生同步 flat
// shadow_read: 同 dual_write
// aeav_only : 寫 flat,return true 短路 WP(不寫 EAV)
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) ) {
return $check; // disabled
@@ -105,6 +114,13 @@ final class TMDO_Hook_Bus {
return true;
}
// dual_write / shadow_read: native first. Writing flat here, before WP
// decides whether native changes at all (same value, non-matching
// prev_value), let flat drift; sync_from_native() runs after the write.
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
// 截取 before value(v1.3.1):audit_logger 等訂閱者需要變更前的值。
// v1.3.2:透過 filter `wpdo_capture_before_value` 可關閉以省一次 DB read。
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'update' );
@@ -113,27 +129,22 @@ final class TMDO_Hook_Bus {
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'update', $before_value );
// 若 mode 也要寫 EAV → return null 讓 WP 繼續
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null; // dual_write / shadow_read
}
return $flat_result; // aeav_only
}
public static function intercept_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $object_id . ':' . $meta_key ] ) ) {
return $check;
}
$type = self::resolve_type_from_current_filter();
if ( ! $type ) {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
// 已在短路中 → 避免遞迴
if ( ! empty( self::$internal_ops[ $type . ':' . $object_id . ':' . $meta_key ] ) ) {
return $check;
}
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
if ( ! $field_def ) {
return $check;
}
@@ -150,6 +161,12 @@ final class TMDO_Hook_Bus {
return true;
}
// dual_write / shadow_read: native first (add($unique) may be refused);
// sync_from_native() runs on added_{type}_meta.
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
// UAE 的設計:每個 entity 只有 1 row,所以 add 與 update 等效(Upsert)。
// 截取 before value(v1.3.1):add 情境下多半為 null,但若 row 已存在而 user 呼叫 add 也能抓到舊值。
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'add' );
@@ -158,11 +175,228 @@ final class TMDO_Hook_Bus {
do_action( 'wpdo_after_write', $type, (int) $object_id, $meta_key, $meta_value, $flat_result, 'add', $before_value );
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
return $flat_result;
}
return $flat_result;
// ─────────────────────────────────────────────────────────
// dual_write / shadow_read:原生寫入後同步 flat
// ─────────────────────────────────────────────────────────
/**
* Entity IDs whose native rows a pending delete removes, keyed "type:key".
*
* @var array<string, int[]>
*/
private static array $pending_deletes = array();
/**
* Old keys of rows renamed through update_metadata_by_mid, keyed by meta ID.
*
* @var array<string, array{type:string, id:int, key:string}>
*/
private static array $pending_renames = array();
/**
* Copy the entity's current native value for a key into its flat column
* (NULL when native has no row). Runs in dual_write / shadow_read only,
* after WP wrote native, so flat always ends up equal to native — also
* when WP skipped or refused the write, and when an earlier direct SQL
* write had left flat stale. A NULL never creates a row: deleting an
* entity drops its flat row before WP deletes the meta rows one by one.
*
* @param string $type Entity type.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Value the caller wrote (for listeners).
* @param string $op 'add' | 'update' | 'delete'.
* @param bool $delete_all Whether the delete was a delete_all.
* @return void
*/
public static function sync_from_native( string $type, int $object_id, string $meta_key, $meta_value, string $op, bool $delete_all = false ): void {
if ( ! TMDO_Mode_Manager::writes_to_flat( $type ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return;
}
$field_def = self::field_for( $type, $object_id, $meta_key );
if ( ! $field_def || 'flat' !== self::decide_route( $op, $type, $object_id, $meta_key, $meta_value ) ) {
return;
}
$before = self::maybe_read_before_value( $type, $object_id, $meta_key, $field_def, $op );
$native = self::native_first_value( $type, $object_id, $meta_key );
$result = null === $native
? self::clear_column( $type, $object_id, $meta_key, $field_def )
: self::perform_upsert( $type, $object_id, $meta_key, maybe_unserialize( $native ), $field_def );
if ( 'delete' === $op ) {
do_action( 'wpdo_after_delete', $type, $object_id, $meta_key, $meta_value, $result, $delete_all, $before );
} else {
do_action( 'wpdo_after_write', $type, $object_id, $meta_key, $meta_value, $result, $op, $before );
}
}
/**
* `added_{type}_meta` action.
*
* @param int $meta_id Meta ID.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function on_meta_added( $meta_id, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( $type ) {
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'add' );
}
}
/**
* `updated_{type}_meta` action; also re-syncs the old key of a row renamed
* through update_metadata_by_mid.
*
* @param int $meta_id Meta ID.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function on_meta_updated( $meta_id, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( ! $type ) {
return;
}
self::sync_from_native( $type, (int) $object_id, (string) $meta_key, $meta_value, 'update' );
$rename_key = $type . ':' . (int) $meta_id;
if ( isset( self::$pending_renames[ $rename_key ] ) ) {
$old = self::$pending_renames[ $rename_key ];
unset( self::$pending_renames[ $rename_key ] );
self::sync_from_native( $type, $old['id'], $old['key'], null, 'delete' );
}
}
/**
* `update_{type}_metadata_by_mid` filter: remember the old key when a row
* is renamed, so its flat column can follow. Never changes $check.
*
* @param mixed $check Short-circuit value.
* @param int $meta_id Meta ID.
* @param mixed $meta_value New value.
* @param string|false $meta_key New key, or false to keep it.
* @return mixed
*/
public static function before_update_by_mid( $check, $meta_id, $meta_value, $meta_key ) {
$type = self::resolve_type_from_meta_hook();
if ( ! $type || false === $meta_key ) {
return $check;
}
$row = self::native_row_by_mid( $type, (int) $meta_id );
if ( $row && $row['key'] !== $meta_key && TMDO_Entity_Registry::get_field( $type, $row['key'] ) ) {
self::$pending_renames[ $type . ':' . (int) $meta_id ] = array(
'type' => $type,
'id' => $row['id'],
'key' => $row['key'],
);
}
return $check;
}
/**
* `delete_{type}_meta` action (rows still exist): record which entities a
* delete touches — a delete_all passes object ID 0.
*
* @param int[] $meta_ids Meta IDs to delete.
* @param int $object_id Entity ID (0 for delete_all).
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function before_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( ! $type || ! TMDO_Entity_Registry::get_field( $type, (string) $meta_key ) || ! TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return;
}
$ids = array();
foreach ( (array) $meta_ids as $mid ) {
$row = self::native_row_by_mid( $type, (int) $mid );
if ( $row ) {
$ids[ $row['id'] ] = $row['id'];
}
}
self::$pending_deletes[ $type . ':' . $meta_key ] = array_values( $ids );
}
/**
* `deleted_{type}_meta` action: re-sync every entity the delete touched.
*
* @param int[] $meta_ids Deleted meta IDs.
* @param int $object_id Entity ID (0 for delete_all).
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
public static function on_meta_deleted( $meta_ids, $object_id, $meta_key, $meta_value ): void {
$type = self::resolve_type_from_meta_hook();
if ( ! $type ) {
return;
}
$pending_key = $type . ':' . $meta_key;
$ids = self::$pending_deletes[ $pending_key ] ?? array( (int) $object_id );
unset( self::$pending_deletes[ $pending_key ] );
foreach ( $ids as $id ) {
if ( $id > 0 ) {
self::sync_from_native( $type, (int) $id, (string) $meta_key, $meta_value, 'delete', 0 === (int) $object_id );
}
}
}
/**
* First native value of a key (the one get_*_meta( $id, $key, true ) returns),
* read with SQL so no meta filter runs. Null when the entity has no row.
*
* @param string $type Entity type.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @return string|null
*/
private static function native_first_value( string $type, int $object_id, string $meta_key ): ?string {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
// get_results(): get_var() turns an empty string into null.
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$pk}` LIMIT 1", $object_id, $meta_key ),
ARRAY_A
);
return $rows ? (string) $rows[0]['meta_value'] : null;
}
/**
* Entity ID and key of a native meta row.
*
* @param string $type Entity type.
* @param int $meta_id Meta ID.
* @return array{id:int, key:string}|null
*/
private static function native_row_by_mid( string $type, int $meta_id ): ?array {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
if ( ! $adapter ) {
return null;
}
$table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
$row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key AS mkey FROM `{$table}` WHERE `{$pk}` = %d", $meta_id ), ARRAY_A );
if ( ! $row ) {
return null;
}
return array(
'id' => (int) $row['id'],
'key' => (string) $row['mkey'],
);
}
/**
@@ -291,6 +525,34 @@ final class TMDO_Hook_Bus {
return 'flat';
}
/**
* Set a flat column to NULL on the entity's existing row; never insert one.
*
* @param string $type Entity type.
* @param int $entity_id Entity ID.
* @param string $meta_key Meta key.
* @param array<string, mixed> $field_def Registered field.
* @return bool|null Null when there is no adapter or table.
*/
private static function clear_column( string $type, int $entity_id, string $meta_key, array $field_def ): ?bool {
global $wpdb;
$adapter = TMDO_Entity_Registry::get_adapter( $type );
$table = TMDO_Schema_Manager::get_table_name( $type, $field_def['group'] );
if ( ! $adapter || ! TMDO_Schema_Manager::table_exists( $table ) ) {
return null;
}
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $wpdb->update( $table, array( $col => null ), array( $adapter->get_entity_id_column() => $entity_id ) );
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $field_def['group'] );
self::touch_posts_last_changed( $type );
return false !== $result;
}
/**
* 執行 Upsert 操作
*/
@@ -322,25 +584,31 @@ final class TMDO_Hook_Bus {
$db_value = TMDO_Type_Caster::to_db( $meta_value, $field_def );
$format = TMDO_Type_Caster::get_wpdb_format( $field_def['type'] );
// 鎖防遞迴
$lock_key = $entity_id . ':' . $meta_key;
// 鎖防遞迴(含 entity type:user 5 與 post 5 不可互鎖)
$lock_key = $type . ':' . $entity_id . ':' . $meta_key;
self::$internal_ops[ $lock_key ] = true;
try {
// 單一 upsert,避免 SELECT→UPDATE/INSERT 的 read-modify-write race。
// post:新列一併帶入 post_status / post_date 副本(只在 insert 時寫入)。
$extra = 'post' === $type ? TMDO_Post_Status_Sync::insert_columns( $entity_id, $group ) : array();
$result = TMDO_DB::upsert(
$table,
array_merge(
array(
$id_col => $entity_id,
$col => $db_value,
),
$extra
),
array( $col ),
$id_col,
array( '%d', $format )
array_merge( array( '%d', $format ), array_fill( 0, count( $extra ), '%s' ) )
);
// 清除快取
TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group );
self::touch_posts_last_changed( $type );
// 短路回傳 true(WP 認為寫入成功)
return $result !== false;
@@ -374,7 +642,7 @@ final class TMDO_Hook_Bus {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
if ( ! $field_def ) {
return $check;
}
@@ -382,12 +650,20 @@ final class TMDO_Hook_Bus {
// ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff
// shadow_read : 讀取仍走 EAV,抽樣與 flat 比對記錄 diff
// aeav_only : 讀取走 UAE flat,不讀 EAV
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
return $check; // disabled / dual_write
}
// shadow_read serves native: update_metadata() reads the old value
// through this filter, and serving the just-upserted flat value made
// WP see old === new and skip the native write (dev36, 2026-09-26).
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
self::maybe_shadow_compare( $type, (int) $object_id, $meta_key, $field_def );
return $check;
}
$group = $field_def['group'];
$row = self::get_or_load_row( $type, (int) $object_id, $group );
@@ -395,22 +671,6 @@ final class TMDO_Hook_Bus {
$has_value = is_array( $row ) && array_key_exists( $col, $row );
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
// Shadow-read:與 EAV 比對,記錄差異
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
try {
TMDO_Shadow_Diff_Logger::compare_and_log(
$type,
(int) $object_id,
$meta_key,
$value,
$field_def
);
} catch ( \Throwable $e ) {
// 比對失敗不該影響讀取
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
}
}
// 值為空 → 回 WP 原生慣例
if ( empty( $row ) || $value === null || $value === '' ) {
return $single ? '' : array();
@@ -420,6 +680,36 @@ final class TMDO_Hook_Bus {
return $single ? array( $value ) : array( $value );
}
/**
* shadow_read: compare the flat value with native meta for a sample of reads.
*
* Each compare costs a flat row read and a native SQL read, so only a share
* of reads is checked (filter `tmdo_shadow_compare_sample_rate`, 0..1,
* default 0.1). The logger rate-limits repeated diffs itself.
*
* @param string $type Entity type.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param array<string, mixed> $field_def Field definition.
*/
private static function maybe_shadow_compare( string $type, int $object_id, string $meta_key, array $field_def ): void {
$rate = (float) apply_filters( 'tmdo_shadow_compare_sample_rate', 0.1, $type, $meta_key );
if ( $rate <= 0 || ( $rate < 1 && wp_rand( 1, 1000 ) > $rate * 1000 ) ) {
return;
}
$row = self::get_or_load_row( $type, $object_id, $field_def['group'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
$value = array_key_exists( $col, $row ) ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
try {
TMDO_Shadow_Diff_Logger::compare_and_log( $type, $object_id, $meta_key, $value, $field_def );
} catch ( \Throwable $e ) {
// 比對失敗不該影響讀取
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
}
}
/**
* 取得(或載入)完整列資料,並快取
*/
@@ -471,7 +761,7 @@ final class TMDO_Hook_Bus {
return $check;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
$field_def = self::field_for( $type, (int) $object_id, $meta_key );
if ( ! $field_def ) {
return $check;
}
@@ -489,6 +779,12 @@ final class TMDO_Hook_Bus {
return true;
}
// dual_write / shadow_read: WP deletes native (also delete_all);
// deleted_{type}_meta re-syncs every touched entity from native.
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
// 截取 before value(v1.3.1)
$before_value = self::maybe_read_before_value( $type, (int) $object_id, $meta_key, $field_def, 'delete' );
@@ -569,14 +865,11 @@ final class TMDO_Hook_Bus {
TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group );
}
self::touch_posts_last_changed( $type );
do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value );
// 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta
if ( TMDO_Mode_Manager::writes_to_eav( $type ) ) {
return null;
}
return $result !== false;
return $result !== false; // aeav_only
}
// ─────────────────────────────────────────────────────────
@@ -608,6 +901,21 @@ final class TMDO_Hook_Bus {
// 工具方法
// ─────────────────────────────────────────────────────────
/**
* Flat post writes skip update_metadata()'s own hooks in aeav_only, so
* move posts' last_changed here: caches keyed on it (WP query caches,
* routed found-rows counts) must not outlive the write.
*
* @param string $type Entity type.
* @return void
*/
private static function touch_posts_last_changed( string $type ): void {
if ( 'post' === $type ) {
wp_cache_set_posts_last_changed();
TMDO_Post_Query_Router::bump_count_version();
}
}
/**
* 從當前 filter 名稱推斷實體類型
* 例:update_user_metadata → user
@@ -622,6 +930,44 @@ final class TMDO_Hook_Bus {
return null;
}
/**
* Entity type from the meta action / by-mid filter being run
* (added_user_meta, deleted_post_meta, update_term_metadata_by_mid, …).
*/
private static function resolve_type_from_meta_hook(): ?string {
if ( preg_match( '/^(?:(?:added|updated|delete|deleted)_(post|user|term|comment)_meta|update_(post|user|term|comment)_metadata_by_mid)$/', current_filter(), $matches ) ) {
return '' !== $matches[1] ? $matches[1] : $matches[2];
}
return null;
}
/**
* 欄位定義查詢;post meta 依該 post 本身的 post_type 解析(見 TMDO_Entity_Registry::get_field())。
*
* 先做不帶 post_type 的查詢:大多數 key 不受管理,可省下 get_post_type()。
* @return array<string, mixed>|null
*/
private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( null !== $field_def && 'post' === $type ) {
$field_def = 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 );
}
/**
* 直接讀取(繞過 WP filter 系統)
* 供 wpdo_get_meta() 便利函式使用,性能更好
@@ -637,18 +983,22 @@ final class TMDO_Hook_Bus {
// 回傳所有群組的所有欄位
$result = array();
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 ) {
$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;
}
$field_def = TMDO_Entity_Registry::get_field( $type, $key );
$field_def = self::field_for( $type, $entity_id, $key );
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'] );
+11 -7
View File
@@ -65,16 +65,15 @@ final class TMDO_Mode_Manager {
private static ?array $cache = null;
/**
* 預設模式(v2.5.4 起):
* post → disabled(由 Legacy Feature_Flags FSM 管理,不由 Mode_Manager 控制)
* user / term / comment → dual_write(安全雙寫:寫入 flat table + 原生 EAV,讀取仍走 EAV)
* 預設模式:全部 disabled,由管理者明確選擇後才啟用 entity bridge。
* 已存在 wpdo_bridge_modes 的站台(包含 aeav_only)以儲存值為準,不受影響。
*/
private static function defaults(): array {
return array(
'post' => self::MODE_DISABLED,
'user' => self::MODE_DUAL_WRITE,
'term' => self::MODE_DUAL_WRITE,
'comment' => self::MODE_DUAL_WRITE,
'user' => self::MODE_DISABLED,
'term' => self::MODE_DISABLED,
'comment' => self::MODE_DISABLED,
);
}
@@ -141,6 +140,11 @@ final class TMDO_Mode_Manager {
);
}
$guarded = TMDO_Promotion_Guard::check_promotion( $entity_type, $new_mode );
if ( $guarded ) {
return $guarded;
}
$all = self::all();
$all[ $entity_type ] = $new_mode;
@@ -155,7 +159,7 @@ final class TMDO_Mode_Manager {
$entered[ $entity_type ] = time();
update_option( self::OPT_ENTERED_AT, $entered, false );
TMDO_Logger::info(
TMDO_Logger::notice(
'bridge_mode_changed',
array(
'entity_type' => $entity_type,
@@ -0,0 +1,266 @@
<?php
/**
* Copies of posts.post_status / post_date on post entity tables (O-1).
*
* With the copies (and their `(post_status, post_date)` index) a routed search
* can filter and sort inside the flat table instead of joining back to posts.
*
* Columns only reach a table two ways, never as DDL on a populated table in a
* web request:
* - a brand-new (empty) group table gets them when it is created;
* - `wp tmdo post-status-sync` adds them to existing tables and backfills.
*
* Per-group state lives in the TMDO_Post_Status_Sync::OPTION option:
* 'columns' (added, backfill pending) or 'ready' (every row copied).
* Afterwards transition_post_status keeps the copies current, and flat writes
* that create a row fill them in.
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Keeps post_status / post_date copies on post entity tables.
*/
final class TMDO_Post_Status_Sync {
public const OPTION = 'tmdo_post_status_columns';
private const INDEX = 'idx_post_status_date_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.
*
* @return void
*/
public static function register(): void {
add_action( 'transition_post_status', array( __CLASS__, 'on_transition' ), 10, 3 );
add_action( 'wpdo_schema_updated', array( __CLASS__, 'on_schema_updated' ), 10, 3 );
}
/**
* '' (no columns), 'columns' or 'ready'.
*
* @param string $group Post group name.
* @return string
*/
public static function state( string $group ): string {
$states = get_option( self::OPTION, array() );
return is_array( $states ) ? (string) ( $states[ $group ] ?? '' ) : '';
}
/**
* Every post status change or save: refresh the copies in each table that has them.
*
* @param string $new_status New status.
* @param string $old_status Old status.
* @param object $post Saved post.
* @return void
*/
public static function on_transition( $new_status, $old_status, $post ): void {
global $wpdb;
unset( $new_status, $old_status );
foreach ( self::groups_with_columns() as $group ) {
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
self::forget( $group ); // The table was dropped (retired or uninstalled group).
continue;
}
$wpdb->query(
$wpdb->prepare(
"UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
$post->post_status,
$post->post_date,
$post->ID
)
);
}
}
/**
* A post group table was just created or altered: an empty one gets the
* columns now; one holding rows waits for the CLI.
*
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Table name.
* @return void
*/
public static function on_schema_updated( $entity_type, $group, $table ): void {
global $wpdb;
if ( 'post' !== $entity_type || '' !== self::state( $group ) ) {
return;
}
if ( null !== $wpdb->get_var( "SELECT 1 FROM `{$table}` LIMIT 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
return;
}
if ( self::add_columns( $group ) ) {
self::set_state( $group, 'ready' );
}
}
/**
* Copies for a row a flat write is about to create (empty when the
* group's table has no such columns).
*
* @param int $post_id Post ID.
* @param string $group Group name.
* @return array{post_status?:string, post_date?:string}
*/
public static function insert_columns( int $post_id, string $group ): array {
if ( '' === self::state( $group ) ) {
return array();
}
$post = get_post( $post_id );
if ( ! $post ) {
return array();
}
return array(
'post_status' => $post->post_status,
'post_date' => $post->post_date,
);
}
/**
* Add the columns to every post group table and copy posts' values in.
*
* @param bool $dry_run Count mismatching rows only.
* @param int $batch Rows per UPDATE.
* @return array<string, array{rows:int, mismatched:int}>
*/
public static function run( bool $dry_run = false, int $batch = 1000 ): array {
global $wpdb;
$batch = max( 1, $batch );
$report = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
$report[ $group ] = array(
'rows' => $rows,
'mismatched' => '' === self::state( $group ) ? $rows : self::mismatches( $group ),
);
if ( $dry_run || ! self::add_columns( $group ) ) {
continue;
}
$max = (int) $wpdb->get_var( "SELECT MAX(id) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
for ( $from = 0; $from < $max; $from += $batch ) {
$wpdb->query(
$wpdb->prepare(
"UPDATE `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id SET t.post_status = p.post_status, t.post_date = p.post_date WHERE t.id > %d AND t.id <= %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
$from,
$from + $batch
)
);
}
self::set_state( $group, 'ready' );
}
// 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;
}
/**
* Rows whose copies differ from posts (rows of deleted posts excluded).
*
* @param string $group Group name.
* @return int
*/
public static function mismatches( string $group ): int {
global $wpdb;
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
return (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$table}` t JOIN `{$wpdb->posts}` p ON p.ID = t.post_id WHERE NOT ( t.post_status <=> p.post_status ) OR NOT ( t.post_date <=> p.post_date )" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
);
}
/**
* Add whichever of the two columns and the index the table lacks.
*
* @param string $group Group name.
* @return bool False when the table is missing or the ALTER failed.
*/
private static function add_columns( string $group ): bool {
global $wpdb;
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
return false;
}
$columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
$indexes = array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name.
$changes = array();
if ( ! in_array( 'post_status', $columns, true ) ) {
// Same collation as posts.post_status: the table default may differ,
// and comparing the two columns would then fail (illegal mix of collations).
$source = $wpdb->get_row( "SHOW FULL COLUMNS FROM `{$wpdb->posts}` LIKE 'post_status'", ARRAY_A );
$collation = preg_replace( '/[^A-Za-z0-9_]/', '', (string) ( $source['Collation'] ?? '' ) );
$changes[] = 'ADD COLUMN `post_status` VARCHAR(20)' . ( '' !== $collation ? " COLLATE {$collation}" : '' ) . ' DEFAULT NULL';
}
if ( ! in_array( 'post_date', $columns, true ) ) {
$changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL';
}
if ( ! in_array( self::INDEX, $indexes, true ) ) {
// 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.
return false;
}
if ( '' === self::state( $group ) ) {
self::set_state( $group, 'columns' );
}
return true;
}
/**
* Post groups whose table has the columns.
*
* @return string[]
*/
private static function groups_with_columns(): array {
$states = get_option( self::OPTION, array() );
return is_array( $states ) ? array_keys( $states ) : array();
}
/**
* 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.
*
* @param string $group Group name.
* @param string $state 'columns' | 'ready'.
* @return void
*/
private static function set_state( string $group, string $state ): void {
$states = get_option( self::OPTION, array() );
$states = is_array( $states ) ? $states : array();
$states[ $group ] = $state;
update_option( self::OPTION, $states, true );
}
}
@@ -0,0 +1,140 @@
<?php
/**
* TMDO_Promotion_Guard — keeps native meta authoritative on HivePress /
* WooCommerce sites (ADR-004 "search-index mode").
*
* Both plugins read native meta without going through a keyed
* get_*_meta() call: HivePress models load every key with get_*_meta( $id ),
* WooCommerce does the same for products and writes stock / sales with direct
* SQL. The Hook Bus cannot serve those reads from flat tables, so on these
* sites an entity must never reach aeav_only and native rows must never be
* cleaned up. Demotion always stays open.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Refuses aeav_only promotion and native-meta cleanup where an integration
* reads native meta directly.
*/
final class TMDO_Promotion_Guard {
/**
* Entity types each integration reads natively.
*
* HivePress models (Post, User, Term, Comment) all load meta with an empty
* key; WooCommerce does so for products and customers.
*/
private const GUARDED = array(
'HivePress' => array( 'post', 'user', 'term', 'comment' ),
'WooCommerce' => array( 'post', 'user' ),
);
/**
* Check a mode change. Only promotion to aeav_only is guarded.
*
* @param string $entity_type Entity type.
* @param string $new_mode Target mode.
* @return WP_Error|null Error when refused.
*/
public static function check_promotion( string $entity_type, string $new_mode ): ?WP_Error {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $new_mode ) {
return null;
}
return self::refuse( $entity_type, TMDO_Mode_Manager::MODE_AEAV_ONLY );
}
/**
* Check a cleanup of the entity's native managed meta rows.
*
* @param string $entity_type Entity type.
* @return WP_Error|null Error when refused.
*/
public static function check_cleanup( string $entity_type ): ?WP_Error {
return self::refuse( $entity_type, 'cleanup' );
}
/**
* Guarded entity types that are already in aeav_only (promoted before the
* guard existed). Native meta may be stale there.
*
* @return array<string, string[]> Entity type → integration names.
*/
public static function violations(): array {
$out = array();
foreach ( TMDO_Mode_Manager::all() as $type => $mode ) {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
continue;
}
$integrations = self::integrations_for( $type );
if ( ! empty( $integrations ) ) {
$out[ $type ] = $integrations;
}
}
return $out;
}
/**
* Active integrations that read this entity type natively.
*
* @param string $entity_type Entity type.
* @return string[]
*/
private static function integrations_for( string $entity_type ): array {
$active = array(
'HivePress' => TMDO_Compatibility::is_hivepress_active(),
'WooCommerce' => TMDO_Compatibility::is_woocommerce_active(),
);
$names = array();
foreach ( self::GUARDED as $name => $types ) {
if ( $active[ $name ] && in_array( $entity_type, $types, true ) ) {
$names[] = $name;
}
}
return $names;
}
/**
* Build the refusal, unless no integration applies or a filter allows it.
*
* @param string $entity_type Entity type.
* @param string $action 'aeav_only' or 'cleanup'.
* @return WP_Error|null
*/
private static function refuse( string $entity_type, string $action ): ?WP_Error {
$integrations = self::integrations_for( $entity_type );
if ( empty( $integrations ) ) {
return null;
}
/**
* Allow a guarded aeav_only promotion or cleanup anyway.
*
* @param bool $allow Default false.
* @param string $entity_type Entity type.
* @param string $action 'aeav_only' or 'cleanup'.
* @param string[] $integrations Integrations that read this entity natively.
*/
if ( apply_filters( 'tmdo_promotion_guard_allow', false, $entity_type, $action, $integrations ) ) {
return null;
}
return new WP_Error(
'promotion_guarded',
sprintf(
/* translators: 1: integration names, 2: entity type, 3: aeav_only or cleanup */
__( '%1$s 直接讀取原生 meta,%2$s 不能執行 %3$s:這類站台以原生 meta 為唯一資料來源(搜尋索引模式)。', '2meet-data-optimizer' ),
implode( ' / ', $integrations ),
$entity_type,
$action
)
);
}
}
+467 -2
View File
@@ -20,6 +20,15 @@ defined( 'ABSPATH' ) || exit;
final class TMDO_Schema_Manager {
/** Option: "entity/group" → md5 of the composite index definition already applied. */
public const INDEXES_OPTION = 'tmdo_entity_indexes';
/** 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. */
private static array $table_exists_cache = array();
@@ -60,6 +69,7 @@ final class TMDO_Schema_Manager {
$schema['group'],
$schema['fields']
);
self::ensure_group_indexes( $schema['type'], $schema['group'] );
}
TMDO_Entity_Registry::clear_pending_schemas();
@@ -73,13 +83,13 @@ final class TMDO_Schema_Manager {
string $group_name,
array $field_definitions
): bool {
global $wpdb;
// Schema 版本比對:若未變動則跳過
$schema_hash = self::calculate_schema_hash( $field_definitions );
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
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;
}
@@ -89,6 +99,54 @@ final class TMDO_Schema_Manager {
}
$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();
$id_col = $adapter->get_entity_id_column();
@@ -140,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 );
$indexes_sql = implode( ",\n ", $sql_indexes );
@@ -153,8 +216,27 @@ final class TMDO_Schema_Manager {
// Bust the request-scoped table_exists cache so subsequent calls see the new table.
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 版本與定義
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
if ( $existed ) {
self::end_deferral( $entity_type, $group_name );
}
/**
* Action: 表建立/升級完成
@@ -164,6 +246,282 @@ final class TMDO_Schema_Manager {
return true;
}
/**
* Add the group's declared composite indexes that its table lacks.
*
* Indexes are not part of the schema hash, so an existing table (which
* may already hold aeav_only data) gets them in place — no rebuild. The
* applied definition is remembered per group (one autoloaded option), so
* requests only run SHOW INDEX / ALTER when the declaration changes.
*
* 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 $group_name Group name.
* @return void
*/
public static function ensure_group_indexes( string $entity_type, string $group_name ): void {
global $wpdb;
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
if ( empty( $declared ) ) {
return;
}
$id = "{$entity_type}/{$group_name}";
$signature = md5( (string) wp_json_encode( $declared ) );
$applied = get_option( self::INDEXES_OPTION, array() );
$applied = is_array( $applied ) ? $applied : array();
if ( ( $applied[ $id ] ?? '' ) === $signature ) {
return;
}
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 );
if ( ! self::table_exists( $table ) ) {
return;
}
if ( ! self::lock() ) {
return; // Another process is adding indexes.
}
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,
)
);
}
}
if ( ! $failed ) {
$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;
}
/**
* 計算 Schema Hash(用於偵測欄位變動)
*/
@@ -268,6 +626,113 @@ final class TMDO_Schema_Manager {
);
}
/**
* 還原孤兒群組:扁平表還在、定義存在 wpdo_registry_meta,但已沒有外掛註冊。
*
* 在所有註冊都完成後(init:1 晚綁定)呼叫。只處理 aeav_only 的實體:其他
* 模式仍會寫原生 meta,孤兒 key 從原生讀取即正確;全部非 aeav_only 時不查資料庫。
* 原生 meta 已有資料的 key(孤兒期間寫入,比扁平表新)留在原生,不還原。
* 還原只影響讀寫路由,不會建立或修改任何資料表。
*
* @return int 還原的群組數
*/
public static function restore_orphaned_groups(): int {
global $wpdb;
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
return 0;
}
$types = array_values(
array_filter(
TMDO_Entity_Registry::VALID_ENTITY_TYPES,
static fn( $type ) => TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $type )
)
);
if ( empty( $types ) ) {
return 0;
}
$meta_table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT entity_type, group_name, field_definitions FROM `{$meta_table}` WHERE entity_type IN ({$placeholders})", ...$types ),
ARRAY_A
);
$orphans = array();
foreach ( (array) $rows as $row ) {
if ( ! TMDO_Entity_Registry::get_group_fields( $row['entity_type'], $row['group_name'] ) ) {
$orphans[] = $row;
}
}
if ( empty( $orphans ) ) {
return 0;
}
// One SHOW TABLES for all candidates instead of one per orphan.
$existing = (array) $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . TMDO_TABLE_PREFIX ) . '%' ) );
// "entity:key" → native meta has rows. Stable while the key is restored
// (aeav_only writes go to the flat table only), so a day-long cache is safe.
$native_cache = get_transient( 'tmdo_orphan_native_keys' );
$native_cache = is_array( $native_cache ) ? $native_cache : array();
$cache_dirty = false;
$restored = 0;
foreach ( $orphans as $row ) {
if ( ! in_array( self::get_table_name( $row['entity_type'], $row['group_name'] ), $existing, true ) ) {
continue;
}
$fields = json_decode( (string) $row['field_definitions'], true );
if ( ! is_array( $fields ) ) {
continue;
}
$native_keys = array();
foreach ( $fields as $field ) {
$key = (string) ( $field['key'] ?? '' );
if ( '' === $key ) {
continue;
}
$cache_key = $row['entity_type'] . ':' . $key;
if ( ! array_key_exists( $cache_key, $native_cache ) ) {
$native_cache[ $cache_key ] = self::native_meta_has_key( $row['entity_type'], $key );
$cache_dirty = true;
}
if ( $native_cache[ $cache_key ] ) {
$native_keys[] = $key;
}
}
if ( TMDO_Entity_Registry::restore_group( $row['entity_type'], $row['group_name'], $fields, $native_keys ) ) {
++$restored;
}
}
if ( $cache_dirty ) {
set_transient( 'tmdo_orphan_native_keys', $native_cache, DAY_IN_SECONDS );
}
return $restored;
}
/**
* Whether the entity's native meta table holds any row for this key.
*/
private static function native_meta_has_key( string $entity_type, string $meta_key ): bool {
global $wpdb;
$tables = array(
'post' => $wpdb->postmeta,
'user' => $wpdb->usermeta,
'term' => $wpdb->termmeta,
'comment' => $wpdb->commentmeta,
);
if ( empty( $tables[ $entity_type ] ) ) {
return false;
}
return (bool) $wpdb->get_var(
$wpdb->prepare( "SELECT 1 FROM `{$tables[ $entity_type ]}` WHERE meta_key = %s LIMIT 1", $meta_key )
);
}
/**
* 取得已儲存的 Schema hash
*/
@@ -31,6 +31,14 @@ final class TMDO_Shadow_Diff_Logger {
/** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */
public const RATELIMIT_WINDOW = 300;
/**
* Rows per entity type + key per KEY_WINDOW, across all objects: a key
* stale on every object (WooCommerce total_sales, written by direct SQL)
* must not log a row for each object read.
*/
public const KEY_LIMIT = 20;
public const KEY_WINDOW = 3600;
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . 'wpdo_shadow_diffs';
@@ -69,7 +77,7 @@ final class TMDO_Shadow_Diff_Logger {
}
// 不一致 → 檢查 rate limit
if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) ) {
if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) || self::key_over_limit( $entity_type, $meta_key ) ) {
return;
}
@@ -183,6 +191,21 @@ final class TMDO_Shadow_Diff_Logger {
return false;
}
/**
* Count this diff against the key's KEY_LIMIT; true once the limit is
* reached within the current KEY_WINDOW (the window starts at its first row).
*/
private static function key_over_limit( string $entity_type, string $meta_key ): bool {
$key = 'wpdo_sd_kl_' . md5( "{$entity_type}:{$meta_key}" );
$state = get_transient( $key );
$state = is_array( $state ) ? $state : array( 0, time() );
if ( $state[0] >= self::KEY_LIMIT ) {
return true;
}
set_transient( $key, array( $state[0] + 1, $state[1] ), max( 1, $state[1] + self::KEY_WINDOW - time() ) );
return false;
}
/**
* 寫入 diff row
*
+35 -3
View File
@@ -70,10 +70,12 @@ final class TMDO_Type_Caster {
return $value;
case 'text':
return (string) $value;
return self::fit_text( is_scalar( $value ) ? (string) $value : maybe_serialize( $value ), $field_def );
case 'textarea':
return (string) $value;
// Arrays / objects are serialized like WP stores them in native meta;
// (string) would store the literal "Array".
return is_scalar( $value ) ? (string) $value : maybe_serialize( $value );
default:
// 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為)
@@ -81,6 +83,33 @@ final class TMDO_Type_Caster {
}
}
/** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */
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 轉換(讀取)
*/
@@ -111,9 +140,12 @@ final class TMDO_Type_Caster {
return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value;
case 'enum':
return (string) $value;
case 'text':
case 'textarea':
return (string) $value;
// Arrays were stored serialized (to_db); plain strings pass through.
return TMDO_Safe_Unserialize::run( (string) $value );
default:
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
@@ -15,13 +15,13 @@ if ( ! defined( 'ABSPATH' ) ) {
}
/**
* Registers four user entity groups for membership, activity, profile, and SSO.
* Registers the user entity groups for membership, activity and profile.
*
* Groups → flat tables:
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
* sso → wp_wpdo_user_sso (Hub token cache, replaces _tmso_* usermeta)
* (sso → wp_wpdo_user_sso is owned by 2meet-data-optimizer-sso-addon)
*
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
@@ -43,6 +43,7 @@ final class TMDO_Member_Fields {
* V2.7.0: Adds four legacy-key groups (core_profile, social, commerce, hp_user)
* to absorb wp_usermeta rows that previously bypassed the entity bridge —
* driving the wp_users:wp_usermeta ratio from 1:5.5 toward 1:2.5.
* (hp_user is now registered by the HivePress AddOn.)
*
* V2.8.4: Adds admin_prefs group — the 7 default keys WP core writes for
* EVERY new user via wp_insert_user (rich_editing, syntax_highlighting,
@@ -60,11 +61,9 @@ final class TMDO_Member_Fields {
self::register_membership_group();
self::register_activity_group();
self::register_profile_group();
self::register_sso_group();
self::register_core_profile_group();
self::register_social_group();
self::register_commerce_group();
self::register_hp_user_group();
self::register_admin_prefs_group();
}
@@ -213,70 +212,6 @@ final class TMDO_Member_Fields {
);
}
/**
* Hub/Spoke SSO token cache — replaces _tmso_* usermeta.
*
* Silent refresh fires every 15 min per user; at 10M users this is a
* high-frequency EAV hot-spot. A flat table + object cache hit cuts DB
* load 10–50× versus a wp_usermeta EAV scan per refresh.
*
* last_id_token is NOT stored in plaintext (privacy + volume). Only the
* SHA-256 hash is kept for SLO token comparison.
*
* @return void
*/
private static function register_sso_group(): void {
TMDO_Entity_Registry::register_group(
'user',
'sso',
array(
array(
'key' => 'hub_global_user_id',
'type' => 'text',
'searchable' => true,
'label' => 'Hub global user UUID (2mso_user_mapping.global_user_id bridge key)',
),
array(
'key' => 'picture_url',
'type' => 'text',
'label' => 'Social / SSO profile picture URL',
),
array(
'key' => 'last_id_token_hash',
'type' => 'text',
'label' => 'SHA-256(last_id_token) for SLO comparison — no plaintext stored',
),
array(
'key' => 'refresh_token_enc',
'type' => 'textarea',
'label' => 'Encrypted refresh token (TMSO_Crypto — key-versioned enc_vN:ciphertext)',
),
array(
'key' => 'token_expires_at',
'type' => 'datetime',
'searchable' => true,
'label' => 'SSO token expiry (set to past to force re-auth on next request)',
),
array(
'key' => 'sso_last_login_at',
'type' => 'datetime',
'label' => 'Last SSO-initiated login datetime',
),
array(
'key' => 'sso_login_count',
'type' => 'integer',
'default' => 0,
'label' => 'SSO login count',
),
array(
'key' => 'known_login_ips',
'type' => 'textarea',
'label' => 'Recent login IPs JSON array (new-device notification) — replaces _tmso_known_ips usermeta',
),
)
);
}
/**
* WP core user fields stored as multi-row EAV in wp_usermeta.
*
@@ -414,34 +349,6 @@ final class TMDO_Member_Fields {
TMDO_Entity_Registry::register_group( 'user', 'commerce', $fields );
}
/**
* HivePress per-user fields (favorites + avatar attachment).
*
* Hp_favorited_listings is a serialized array of post IDs in legacy storage;
* the migration engine safe_unserialize()s it (allowed_classes=false to block
* PHP-object injection) then the json type encodes back to a JSON array column.
*
* @return void
*/
private static function register_hp_user_group(): void {
TMDO_Entity_Registry::register_group(
'user',
'hp_user',
array(
array(
'key' => 'hp_favorited_listings',
'type' => 'json',
'label' => 'HivePress favorited listing IDs (array)',
),
array(
'key' => 'hp_image',
'type' => 'text',
'label' => 'HivePress avatar attachment ID',
),
)
);
}
/**
* WP-core admin pref defaults — written by `wp_insert_user()` for EVERY
* new user regardless of role. Without registering these, a fresh user
+48 -303
View File
@@ -2,28 +2,27 @@
/**
* Post entity field registration for WP Data Optimizer (v2.9.1).
*
* Registers seven post entity groups designed to absorb the bulk of
* Registers three post entity groups designed to absorb the bulk of
* wp_postmeta rows that previously bypassed any flat-table strategy.
* All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV.
*
* Groups → flat tables (post_type targets):
* wp_core → wp_wpdo_post_wp_core (cross post_type)
* attachment → wp_wpdo_post_attachment (attachment)
* wc_product → wp_wpdo_post_wc_product (product)
* hp_listing_core → wp_wpdo_post_hp_listing_core (hp_listing)
* hp_request_core → wp_wpdo_post_hp_request_core (hp_request)
* hp_vendor_core → wp_wpdo_post_hp_vendor_core (hp_vendor)
* nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item)
*
* Pattern mirrors TMDO_Member_Fields::register(). Called from
* TMDO_Core::run() before the wpdo_register_entity_fields action fires;
* Schema_Manager materializes the seven flat tables in init:1
* Schema_Manager materializes the three flat tables in init:1
* via process_pending_migrations() (idempotent via schema_hash compare).
*
* Keys not registered here (HivePress dynamic attrs, WPCS legacy keys,
* etc.) pass through to wp_postmeta unchanged — same fall-back behavior
* as user entity bridge.
*
* The HivePress groups (hp_listing_core / hp_request_core / hp_vendor_core)
* are registered by the HivePress AddOn (TMDO_HivePress_Entity_Groups).
*
* 🔒 v2.9.x frozen contract: this class must NEVER call register_group()
* with entity_type='user'. User entity registration is owned exclusively
* by TMDO_Member_Fields.
@@ -39,10 +38,52 @@ if ( ! defined( 'ABSPATH' ) ) {
}
/**
* Registers seven 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 {
/**
* Post type of each core group that is not scoped to post types in the
* registry (wp_core spans every post type and is absent on purpose).
*/
private const GROUP_POST_TYPES = array(
'attachment' => 'attachment',
'nav_menu_item' => 'nav_menu_item',
);
/**
* Primary post type of a post group: the registry scope for scoped groups
* (e.g. the HivePress AddOn's), the core mapping otherwise.
*
* @param string $group Entity group name.
* @return string|null Null for cross-post-type groups such as wp_core.
*/
public static function post_type_for_group( string $group ): ?string {
$scoped = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_group_post_types( 'post', $group ) : array();
return $scoped[0] ?? ( self::GROUP_POST_TYPES[ $group ] ?? null );
}
/**
* Post groups dedicated to a post type (wp_core excluded).
*
* @param string $post_type Post type slug.
* @return string[]
*/
public static function groups_for_post_type( string $post_type ): array {
$groups = array_keys( self::GROUP_POST_TYPES, $post_type, true );
if ( class_exists( 'TMDO_Entity_Registry' ) ) {
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
if ( in_array( $post_type, TMDO_Entity_Registry::get_group_post_types( 'post', $group ), true ) ) {
$groups[] = $group;
}
}
}
return array_values( array_unique( $groups ) );
}
/**
* Hook into WPDO entity field registration.
*
@@ -64,10 +105,6 @@ final class TMDO_Post_Fields {
self::register_wp_core_group();
self::register_attachment_group();
self::register_wc_product_group();
self::register_hp_listing_core_group();
self::register_hp_request_core_group();
self::register_hp_vendor_core_group();
self::register_nav_menu_item_group();
}
@@ -139,298 +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',
),
)
);
}
/**
* HivePress listing core meta keys (post_type=hp_listing).
* Aligns with the existing wpdo_hot_hp_listing flat table; v2.9.5 will
* copy-then-cutover the 230 dev10 rows to this group's table.
*
* @return void
*/
private static function register_hp_listing_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_listing_core',
array(
array(
'key' => 'hp_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Listing price',
),
array(
'key' => 'hp_status',
'type' => 'enum',
'searchable' => true,
'options' => array( 'publish', 'draft', 'pending', 'expired', 'private' ),
'label' => 'Listing status',
),
array(
'key' => 'hp_featured',
'type' => 'integer',
'searchable' => true,
'label' => 'Featured flag (0/1)',
),
array(
'key' => 'hp_verified',
'type' => 'integer',
'searchable' => true,
'label' => 'Verified flag (0/1)',
),
array(
'key' => 'hp_vendor',
'type' => 'integer',
'searchable' => true,
'label' => 'Vendor user ID',
),
array(
'key' => 'hp_expired_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Expiry unix ts',
),
array(
'key' => 'hp_featured_time',
'type' => 'integer',
'label' => 'Featured-until unix ts',
),
array(
'key' => 'hp_view_count',
'type' => 'integer',
'searchable' => true,
'label' => 'View counter',
),
array(
'key' => 'hp_rating',
'type' => 'decimal',
'searchable' => true,
'label' => 'Average rating',
),
array(
'key' => 'hp_rating_count',
'type' => 'integer',
'label' => 'Rating count',
),
array(
'key' => 'hp_hourly_rate',
'type' => 'decimal',
'label' => 'Hourly rate',
),
)
);
}
/**
* HivePress request core meta keys (post_type=hp_request).
*
* @return void
*/
private static function register_hp_request_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_request_core',
array(
array(
'key' => 'hp_status',
'type' => 'enum',
'searchable' => true,
'options' => array( 'publish', 'draft', 'pending', 'expired' ),
'label' => 'Request status',
),
array(
'key' => 'hp_user',
'type' => 'integer',
'searchable' => true,
'label' => 'Request author user ID',
),
array(
'key' => 'hp_expired_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Expiry unix ts',
),
array(
'key' => 'hp_budget',
'type' => 'decimal',
'searchable' => true,
'label' => 'Budget amount',
),
array(
'key' => 'hp_view_count',
'type' => 'integer',
'label' => 'View counter',
),
)
);
}
/**
* HivePress vendor core meta keys (post_type=hp_vendor).
*
* @return void
*/
private static function register_hp_vendor_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_vendor_core',
array(
array(
'key' => 'hp_user',
'type' => 'integer',
'searchable' => true,
'label' => 'Vendor user ID (linked WP user)',
),
array(
'key' => 'hp_verified',
'type' => 'integer',
'searchable' => true,
'label' => 'Verified flag (0/1)',
),
array(
'key' => 'hp_hourly_rate',
'type' => 'decimal',
'searchable' => true,
'label' => 'Hourly rate',
),
array(
'key' => 'hp_rating_count',
'type' => 'integer',
'label' => 'Rating count',
),
array(
'key' => 'hp_rating',
'type' => 'decimal',
'searchable' => true,
'label' => 'Average rating',
),
)
);
}
/**
* Nav menu item meta keys (post_type=nav_menu_item).
*
@@ -109,12 +109,12 @@ final class TMDO_Term_Comment_Garbage_Filter {
}
/**
* Check the admin toggle. Defaults to enabled.
* Check the admin toggle. Defaults to disabled.
*
* @return bool
*/
public static function is_enabled(): bool {
return (bool) get_option( self::OPT_ENABLED, '1' );
return (bool) get_option( self::OPT_ENABLED, '0' );
}
/**
@@ -95,12 +95,13 @@ final class TMDO_Term_Comment_Misc_Bucket {
}
/**
* Check the admin toggle. Defaults to enabled.
* Check the admin toggle. Defaults to disabled; sites that already hold misc
* rows keep it on via TMDO_Installer::maybe_preserve_misc_bucket_default().
*
* @return bool
*/
public static function is_enabled(): bool {
return (bool) get_option( self::OPT_ENABLED, '1' );
return (bool) get_option( self::OPT_ENABLED, '0' );
}
/**
@@ -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;
}
}
@@ -0,0 +1,438 @@
<?php
/**
* TMDO_Native_Restorer — write flat-table values back to native meta.
*
* The only way out of aeav_only: in that mode the flat tables are the sole
* copy of every managed key, and demoting the entity (or uninstalling) would
* leave reads on native meta that no longer has the data.
*
* Rule: afterwards native meta returns what reads return today.
* - A key is written from the group that currently owns it for that entity
* (TMDO_Entity_Registry::get_field(), by post type for posts), so stale
* copies in other group tables never win.
* - Non-NULL values are stored the way WP stores them (maybe_serialize of
* the value Type_Caster returns); a NULL value deletes the native row.
* - Entities with no row in the owning group's table are not touched:
* leftover native rows for them are outside the flat data.
* - A flat value equal to its field default is not written for an entity
* that has no native row (e.g. activity.session_count = 0): that would
* invent native meta the entity never had.
*
* Policy (which side wins where both hold a value):
* - overwrite — flat wins; the default in aeav_only, where writes only
* reached the flat tables and native is stale.
* - only_missing — native wins, flat only fills gaps; the default in every
* other mode, where native is the source of truth (and may
* be newer, e.g. WooCommerce stock written with direct SQL).
*
* Batched by entity id with a cursor option so an interrupted run resumes; a
* final pass re-copies rows updated since the first run started. With
* $demote the entity then moves to dual_write, which keeps both copies in
* step from then on.
*
* A single group can be written back on its own ($group) and then retired:
* retire() forgets its registry_meta row and keeps the flat table under a
* `_retired_<Ymd>` name, so orphan restore never routes its keys again.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
defined( 'ABSPATH' ) || exit;
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- table/column names come from the entity registry / adapter; placeholders are built for spread params.
/**
* Copies flat-table values of one entity type back into its native meta table.
*/
final class TMDO_Native_Restorer {
/** Option: "entity/group" → last entity id written. */
public const CURSOR_OPTION = 'tmdo_restore_native_cursor';
/** Option: entity → DB time its first (possibly interrupted) run started. */
public const STARTED_OPTION = 'tmdo_restore_native_started';
/**
* Restore one entity type (or, with $dry_run, count the rows involved).
*
* @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
* @param bool $dry_run Count only; write nothing.
* @param int $batch Rows per batch.
* @param bool $demote After a complete run, switch the entity to dual_write.
* @param string $group Only this group ('' = every group).
* @param string $policy 'overwrite' | 'only_missing' | '' (by the entity's mode).
* @return array{groups: array<string, array{rows:int, written:int, missing:int, differs:int, same:int, default_skipped:int}>, demoted: bool, policy: string}
*/
public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '', string $policy = '' ): array {
global $wpdb;
if ( ! in_array( $policy, array( 'overwrite', 'only_missing' ), true ) ) {
$policy = TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ? 'overwrite' : 'only_missing';
}
$report = array(
'groups' => array(),
'demoted' => false,
'policy' => $policy,
);
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return $report;
}
$id_col = $adapter->get_entity_id_column();
$batch = max( 1, $batch );
$started = get_option( self::STARTED_OPTION, array() );
$started = is_array( $started ) ? $started : array();
if ( ! $dry_run && ! isset( $started[ $entity_type ] ) ) {
$started[ $entity_type ] = (string) $wpdb->get_var( 'SELECT NOW()' );
update_option( self::STARTED_OPTION, $started, false );
}
$cursor = get_option( self::CURSOR_OPTION, array() );
$cursor = is_array( $cursor ) ? $cursor : array();
$tables = self::group_tables( $entity_type );
if ( '' !== $group ) {
$tables = array_intersect_key( $tables, array( $group => true ) );
}
foreach ( $tables as $group => $table ) {
$entry = array(
'rows' => 0,
'written' => 0,
'missing' => 0,
'differs' => 0,
'same' => 0,
'default_skipped' => 0,
);
$key = "{$entity_type}/{$group}";
$last = $dry_run ? 0 : (int) ( $cursor[ $key ] ?? 0 );
do {
$rows = self::fetch( $entity_type, $table, $id_col, "t.`{$id_col}` > %d ORDER BY t.`{$id_col}` LIMIT %d", array( $last, $batch ) );
$fetched = count( $rows );
$entry['rows'] += $fetched;
if ( $fetched ) {
$last = (int) end( $rows )[ $id_col ];
foreach ( self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, ! $dry_run ) as $count_key => $n ) {
$entry[ $count_key ] += $n;
}
if ( ! $dry_run ) {
$cursor[ $key ] = $last;
update_option( self::CURSOR_OPTION, $cursor, false );
}
}
} while ( $fetched === $batch );
$report['groups'][ $group ] = $entry;
}
if ( $dry_run ) {
return $report;
}
// Catch-up: writes that landed in the flat tables after a batch passed their row.
foreach ( $tables as $group => $table ) {
$rows = self::fetch( $entity_type, $table, $id_col, 't.updated_at >= %s', array( $started[ $entity_type ] ) );
self::apply_rows( $entity_type, $group, $adapter, $rows, $policy, true );
}
foreach ( array_keys( $tables ) as $group ) {
unset( $cursor[ "{$entity_type}/{$group}" ] );
}
unset( $started[ $entity_type ] );
$cursor ? update_option( self::CURSOR_OPTION, $cursor, false ) : delete_option( self::CURSOR_OPTION );
$started ? update_option( self::STARTED_OPTION, $started, false ) : delete_option( self::STARTED_OPTION );
if ( $demote ) {
$report['demoted'] = true === TMDO_Mode_Manager::set( $entity_type, TMDO_Mode_Manager::MODE_DUAL_WRITE );
}
return $report;
}
/**
* Forget a group that no plugin registers any more, once native meta holds
* every non-NULL value of its flat table (run() with $group first).
*
* @param string $entity_type Entity type.
* @param string $group Group name.
* @return array{status:string, mismatches:int, archived_table:string}
* status: retired | registered (a plugin still registers it) |
* missing (no registry_meta row or flat table) | mismatch (native differs).
*/
public static function retire( string $entity_type, string $group ): array {
global $wpdb;
$result = array(
'status' => 'missing',
'mismatches' => 0,
'archived_table' => '',
);
$restored = array_map( static fn( $r ) => $r['entity_type'] . '/' . $r['group'], TMDO_Entity_Registry::get_restored_groups() );
if ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) && ! in_array( "{$entity_type}/{$group}", $restored, true ) ) {
$result['status'] = 'registered';
return $result;
}
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
$meta_table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
$defs = $wpdb->get_var( $wpdb->prepare( "SELECT field_definitions FROM `{$meta_table}` WHERE entity_type = %s AND group_name = %s", $entity_type, $group ) );
$fields = json_decode( (string) $defs, true );
if ( ! $adapter || ! is_array( $fields ) || null === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) {
return $result;
}
$native_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
foreach ( (array) $wpdb->get_results( "SELECT * FROM `{$table}`", ARRAY_A ) as $row ) {
foreach ( $fields as $field ) {
$col = TMDO_Schema_Manager::sanitize_column_name( (string) ( $field['key'] ?? '' ) );
if ( '' === $col || ! isset( $row[ $col ] ) ) {
continue;
}
$want = (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) );
// get_results(), not get_col(): wpdb reads an empty string as null.
$native = array_column( (array) $wpdb->get_results( $wpdb->prepare( "SELECT meta_value FROM `{$native_table}` WHERE `{$id_col}` = %d AND meta_key = %s", (int) $row[ $id_col ], $field['key'] ), ARRAY_A ), 'meta_value' );
if ( array( $want ) !== $native ) {
++$result['mismatches'];
}
}
}
if ( $result['mismatches'] ) {
$result['status'] = 'mismatch';
return $result;
}
$archived = $table . '_retired_' . gmdate( 'Ymd' );
$wpdb->query( "RENAME TABLE `{$table}` TO `{$archived}`" );
$wpdb->delete(
$meta_table,
array(
'entity_type' => $entity_type,
'group_name' => $group,
)
);
delete_transient( 'tmdo_orphan_native_keys' );
TMDO_Schema_Manager::flush_table_exists_cache();
$result['status'] = 'retired';
$result['archived_table'] = $archived;
return $result;
}
/**
* Registered groups of the entity whose flat table exists.
*
* @param string $entity_type Entity type.
* @return array<string, string> Group → table name.
*/
private static function group_tables( string $entity_type ): array {
$tables = array();
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 ) ) {
$tables[ $group ] = $table;
}
}
return $tables;
}
/**
* Flat rows matching $where; post rows carry their post type as __post_type.
*
* @param string $entity_type Entity type.
* @param string $table Flat table.
* @param string $id_col Entity id column.
* @param string $where WHERE clause with placeholders.
* @param array<int, mixed> $params Placeholder values.
* @return array<int, array<string, mixed>>
*/
private static function fetch( string $entity_type, string $table, string $id_col, string $where, array $params ): array {
global $wpdb;
$select = 'post' === $entity_type
? "SELECT t.*, p.post_type AS __post_type FROM `{$table}` t LEFT JOIN `{$wpdb->posts}` p ON p.ID = t.`{$id_col}`"
: "SELECT t.* FROM `{$table}` t";
return (array) $wpdb->get_results( $wpdb->prepare( "{$select} WHERE {$where}", ...$params ), ARRAY_A );
}
/**
* Compare the native meta of the keys this group owns with the flat rows,
* and (when $write) bring native in line according to $policy.
*
* @param string $entity_type Entity type.
* @param string $group Group the rows come from.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param array<int, array<string, mixed>> $rows Flat rows.
* @param string $policy 'overwrite' | 'only_missing'.
* @param bool $write False = classify only (dry run).
* @return array{written:int, missing:int, differs:int, same:int, default_skipped:int}
*/
private static function apply_rows( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows, string $policy, bool $write ): array {
$counts = array(
'written' => 0,
'missing' => 0,
'differs' => 0,
'same' => 0,
'default_skipped' => 0,
);
list( $wanted, $fields ) = self::flat_values( $entity_type, $group, $adapter, $rows );
if ( empty( $wanted ) ) {
return $counts;
}
$native = self::native_values( $entity_type, $adapter, array_keys( $wanted ), array_keys( $fields ) );
$deletes = array();
$inserts = array();
foreach ( $wanted as $id => $values ) {
foreach ( $values as $key => $value ) {
$has = isset( $native[ $id ] ) && array_key_exists( $key, $native[ $id ] );
if ( null === $value ) {
if ( $has ) {
++$counts['differs'];
if ( 'overwrite' === $policy ) {
$deletes[ $key ][] = $id;
}
}
continue;
}
if ( ! $has ) {
if ( self::is_default( $value, $fields[ $key ] ) ) {
++$counts['default_skipped'];
continue;
}
++$counts['missing'];
$inserts[] = array( $id, $key, $value );
continue;
}
if ( self::same_value( $native[ $id ][ $key ], $value ) ) {
++$counts['same'];
continue;
}
++$counts['differs'];
if ( 'overwrite' === $policy ) {
$deletes[ $key ][] = $id;
$inserts[] = array( $id, $key, $value );
}
}
}
if ( $write ) {
self::write_native( $entity_type, $adapter, $deletes, $inserts, array_keys( $wanted ) );
$counts['written'] = count( $inserts );
}
return $counts;
}
/**
* Values this group would write to native, per entity and key (NULL = none),
* restricted to the keys the group owns for that entity.
*
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param array<int, array<string, mixed>> $rows Flat rows.
* @return array{0: array<int, array<string, string|null>>, 1: array<string, array<string, mixed>>}
*/
private static function flat_values( string $entity_type, string $group, TMDO_Entity_Adapter_Interface $adapter, array $rows ): array {
$id_col = $adapter->get_entity_id_column();
$wanted = array();
$fields = array();
foreach ( $rows as $row ) {
$id = (int) $row[ $id_col ];
$post_type = 'post' === $entity_type ? (string) ( $row['__post_type'] ?? '' ) : '';
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
$owner = TMDO_Entity_Registry::get_field( $entity_type, $field['key'], $post_type );
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
if ( null === $owner || $owner['group'] !== $group || ! array_key_exists( $col, $row ) ) {
continue;
}
$fields[ $field['key'] ] = $field;
$wanted[ $id ][ $field['key'] ] = null === $row[ $col ]
? null
: (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) );
}
}
return array( $wanted, $fields );
}
/**
* First native value per entity and key.
*
* @param string $entity_type Entity type.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param int[] $ids Entity IDs.
* @param string[] $keys Meta keys.
* @return array<int, array<string, string>>
*/
private static function native_values( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $ids, array $keys ): array {
global $wpdb;
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$pk = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
$in_ids = implode( ',', array_map( 'intval', $ids ) );
$in_keys = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$rows = (array) $wpdb->get_results(
$wpdb->prepare( "SELECT `{$id_col}` AS id, meta_key, meta_value FROM `{$meta_table}` WHERE `{$id_col}` IN ({$in_ids}) AND meta_key IN ({$in_keys}) ORDER BY `{$pk}`", ...$keys ),
ARRAY_A
);
$native = array();
foreach ( $rows as $r ) {
if ( ! isset( $native[ (int) $r['id'] ][ $r['meta_key'] ] ) ) {
$native[ (int) $r['id'] ][ $r['meta_key'] ] = (string) $r['meta_value'];
}
}
return $native;
}
/**
* Delete / insert native rows and clear the entities' meta caches.
*
* @param string $entity_type Entity type.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param array<string, int[]> $deletes Meta key → entity IDs to clear first.
* @param array<int, array{0:int, 1:string, 2:string}> $inserts Rows to insert.
* @param int[] $ids Entities touched.
* @return void
*/
private static function write_native( string $entity_type, TMDO_Entity_Adapter_Interface $adapter, array $deletes, array $inserts, array $ids ): void {
global $wpdb;
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
foreach ( $deletes as $meta_key => $del_ids ) {
$in = implode( ',', array_map( 'intval', $del_ids ) );
$wpdb->query( $wpdb->prepare( "DELETE FROM `{$meta_table}` WHERE meta_key = %s AND `{$id_col}` IN ({$in})", $meta_key ) );
}
if ( $inserts ) {
$values = implode( ', ', array_fill( 0, count( $inserts ), '(%d, %s, %s)' ) );
$wpdb->query( $wpdb->prepare( "INSERT INTO `{$meta_table}` (`{$id_col}`, meta_key, meta_value) VALUES {$values}", ...array_merge( ...$inserts ) ) );
}
foreach ( $ids as $id ) {
wp_cache_delete( $id, "{$entity_type}_meta" );
}
}
/**
* 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 );
}
}
@@ -0,0 +1,221 @@
<?php
/**
* TMDO_Post_Group_Reconciler — move post values written under legacy routing.
*
* Before post_type-scoped routing (tmdo_post_scoped_routing), a meta key
* shared by several post groups was read and written through the last group
* registered, e.g. a listing's hp_rating lived in wp_wpdo_post_hp_vendor_core.
* Scoped lookups read it from the listing's own group instead, so those
* values have to be copied there first.
*
* Rule: after the switch every read must return what it returned before.
* - A non-NULL value in the legacy owner's table is what reads served, so
* it overwrites the scoped group's column.
* - NULL / missing legacy values leave the scoped column alone (it holds
* the backfill copy, the only data there is).
* - Source rows are left in place; scoped lookups never read them again.
*
* Batched by entity id with a cursor option so an interrupted run resumes; a
* final pass re-copies legacy rows updated since the first run started, then
* scoped routing is switched on.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
defined( 'ABSPATH' ) || exit;
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- placeholders are built for spread params; table/column names come from the entity registry via Schema_Manager and are sanitized there.
/**
* Copies legacy-routed post values into their post type's group table.
*/
final class TMDO_Post_Group_Reconciler {
/** Option: move id → last entity id copied. */
public const CURSOR_OPTION = 'tmdo_post_reconcile_cursor';
/** Option: DB time the first (possibly interrupted) run started. */
public const STARTED_OPTION = 'tmdo_post_reconcile_started';
/**
* Keys of scoped post groups whose legacy owner is another group.
*
* @return array<int, array{target:string, source:string, key:string, post_types:string[]}>
*/
public static function plan(): array {
$moves = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
$post_types = TMDO_Entity_Registry::get_group_post_types( 'post', $group );
if ( empty( $post_types ) ) {
continue;
}
foreach ( TMDO_Entity_Registry::get_group_keys( 'post', $group ) as $key ) {
$legacy = TMDO_Entity_Registry::get_field( 'post', $key );
if ( null === $legacy || $legacy['group'] === $group ) {
continue;
}
$moves[] = array(
'target' => $group,
'source' => $legacy['group'],
'key' => $key,
'post_types' => $post_types,
);
}
}
return $moves;
}
/**
* Copy the values (or, with $dry_run, count what would be copied).
*
* @param bool $dry_run Count only; write nothing.
* @param int $batch Rows per batch.
* @return array{moves: array<int, array<string, mixed>>, enabled: bool}
*/
public static function run( bool $dry_run = false, int $batch = 500 ): array {
global $wpdb;
$report = array(
'moves' => array(),
'enabled' => false,
);
$adapter = TMDO_Entity_Registry::get_adapter( 'post' );
if ( ! $adapter ) {
return $report;
}
$id_col = $adapter->get_entity_id_column();
$batch = max( 1, $batch );
if ( ! $dry_run && false === get_option( self::STARTED_OPTION ) ) {
update_option( self::STARTED_OPTION, (string) $wpdb->get_var( 'SELECT NOW()' ), false );
}
$cursor = get_option( self::CURSOR_OPTION, array() );
$cursor = is_array( $cursor ) ? $cursor : array();
$moves = self::plan();
foreach ( $moves as $move ) {
list( $from, $params ) = self::source_clause( $move, $id_col );
$entry = array(
'target' => $move['target'],
'source' => $move['source'],
'key' => $move['key'],
'copied' => 0,
'overwritten' => 0,
);
if ( $dry_run ) {
$target = TMDO_Schema_Manager::get_table_name( 'post', $move['target'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] );
$entry['copied'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) {$from}", ...$params ) );
$entry['overwritten'] = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) {$from} AND EXISTS ( SELECT 1 FROM `{$target}` t WHERE t.`{$id_col}` = s.`{$id_col}` AND t.`{$col}` IS NOT NULL AND t.`{$col}` <> s.`{$col}` )",
...$params
)
);
$report['moves'][] = $entry;
continue;
}
$move_id = "{$move['source']}>{$move['target']}:{$move['key']}";
$last = (int) ( $cursor[ $move_id ] ?? 0 );
do {
$rows = (array) $wpdb->get_results(
$wpdb->prepare( "SELECT s.`{$id_col}` AS id, s.`" . TMDO_Schema_Manager::sanitize_column_name( $move['key'] ) . "` AS v {$from} AND s.`{$id_col}` > %d ORDER BY s.`{$id_col}` LIMIT %d", ...array_merge( $params, array( $last, $batch ) ) ),
ARRAY_A
);
$entry['copied'] += self::copy_rows( $move, $id_col, $rows );
$fetched = count( $rows );
if ( $fetched ) {
$last = (int) end( $rows )['id'];
$cursor[ $move_id ] = $last;
update_option( self::CURSOR_OPTION, $cursor, false );
}
} while ( $fetched === $batch );
$report['moves'][] = $entry;
}
if ( $dry_run ) {
return $report;
}
// Catch-up: legacy-routed writes that landed after a batch passed their row.
$started = (string) get_option( self::STARTED_OPTION );
foreach ( $moves as $move ) {
list( $from, $params ) = self::source_clause( $move, $id_col );
$rows = (array) $wpdb->get_results(
$wpdb->prepare( "SELECT s.`{$id_col}` AS id, s.`" . TMDO_Schema_Manager::sanitize_column_name( $move['key'] ) . "` AS v {$from} AND s.updated_at >= %s", ...array_merge( $params, array( $started ) ) ),
ARRAY_A
);
self::copy_rows( $move, $id_col, $rows );
}
update_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, '1', true );
delete_option( self::CURSOR_OPTION );
delete_option( self::STARTED_OPTION );
$report['enabled'] = true;
return $report;
}
/**
* FROM/WHERE for non-NULL legacy values of the move's post types.
*
* @param array<string, mixed> $move One entry of plan().
* @param string $id_col Entity id column of the flat tables.
* @return array{0:string, 1:array<int, mixed>}
*/
private static function source_clause( array $move, string $id_col ): array {
global $wpdb;
$source = TMDO_Schema_Manager::get_table_name( 'post', $move['source'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] );
$types = implode( ',', array_fill( 0, count( $move['post_types'] ), '%s' ) );
return array(
"FROM `{$source}` s INNER JOIN `{$wpdb->posts}` p ON p.ID = s.`{$id_col}` AND p.post_type IN ({$types}) WHERE s.`{$col}` IS NOT NULL",
$move['post_types'],
);
}
/**
* Upsert one column of the target group rows and drop their cached copies.
*
* @param array<string, mixed> $move One entry of plan().
* @param string $id_col Entity id column of the flat tables.
* @param array<int, array<string, mixed>> $rows Source rows: list of { id, v }.
* @return int Rows written.
*/
private static function copy_rows( array $move, string $id_col, array $rows ): int {
global $wpdb;
if ( empty( $rows ) ) {
return 0;
}
$target = TMDO_Schema_Manager::get_table_name( 'post', $move['target'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] );
$values = array();
$params = array();
foreach ( $rows as $row ) {
$values[] = '(%d, %s)';
$params[] = (int) $row['id'];
$params[] = (string) $row['v'];
}
$wpdb->query(
$wpdb->prepare(
"INSERT INTO `{$target}` (`{$id_col}`, `{$col}`) VALUES " . implode( ', ', $values ) . " ON DUPLICATE KEY UPDATE `{$col}` = VALUES(`{$col}`)",
...$params
)
);
if ( class_exists( 'TMDO_Cache_Orchestrator' ) ) {
foreach ( $rows as $row ) {
TMDO_Cache_Orchestrator::invalidate( 'post', (int) $row['id'], $move['target'] );
}
}
return count( $rows );
}
}
@@ -99,11 +99,12 @@ final class TMDO_Post_Migration {
/**
* Bulk SQL pivot for one group: select managed keys from wp_postmeta,
* filter by post_type, pivot via MAX(CASE) GROUP BY post_id, UPSERT
* into the group's flat table.
* into the group's flat table. Groups with json fields go row by row
* through backfill_group_json() instead.
*
* @param string $group_name Entity group name (e.g. 'wc_product').
* @param string $group_name Entity group name (e.g. 'hp_listing_core').
* @return array{migrated:int,group:string,post_type:string|null}
* @throws InvalidArgumentException When group is not registered.
* @throws InvalidArgumentException When group is not registered, or is a restored orphan group.
* @throws RuntimeException When the pivot SQL fails.
*/
public static function backfill_group( string $group_name ): array {
@@ -115,8 +116,30 @@ final class TMDO_Post_Migration {
'Unknown post entity group: ' . esc_html( $group_name )
);
}
if ( ! in_array( $group_name, self::backfillable_groups(), true ) ) {
throw new InvalidArgumentException(
'Orphan post group, its values live only in the flat table: ' . esc_html( $group_name )
);
}
$post_type = self::group_post_type( $group_name );
// json values need PHP-level unserialize → json encode, which the bulk
// pivot below cannot do: backfill such groups row by row instead.
if ( in_array( 'json', array_column( $fields, 'type' ), true ) ) {
$result = self::backfill_group_json( $group_name, array( 'resume' => false ) );
if ( ! empty( $result['error'] ) || ! empty( $result['errors'] ) ) {
throw new RuntimeException(
'backfill_group failed for ' . esc_html( $group_name ) . ': ' . esc_html( (string) ( $result['error'] ?? $result['errors'] . ' row error(s)' ) )
);
}
return array(
'migrated' => (int) $result['migrated'],
'group' => $group_name,
'post_type' => $post_type,
);
}
$flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name );
// Introspect target table columns so we only pivot fields that actually
@@ -131,16 +154,10 @@ final class TMDO_Post_Migration {
}
// Build column list and CASE expressions.
// Skip json/textarea types from the bulk pivot — those need row-by-row
// unserialize handling (deferred to v2.9.4 backfill_unserialize phase).
$columns = array();
$cases = array();
$update_parts = array();
foreach ( $fields as $field ) {
$type = $field['type'] ?? 'text';
if ( 'json' === $type ) {
continue;
}
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
if ( ! isset( $existing_cols[ $col ] ) ) {
continue; // Column not present in this table — skip silently.
@@ -228,6 +245,10 @@ final class TMDO_Post_Migration {
'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only'
);
}
$guarded = TMDO_Promotion_Guard::check_cleanup( self::ENTITY_TYPE );
if ( $guarded ) {
throw new RuntimeException( esc_html( $guarded->get_error_message() ) );
}
global $wpdb;
$keys = self::get_managed_keys();
@@ -532,6 +553,17 @@ final class TMDO_Post_Migration {
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Registered post groups that can be backfilled from wp_postmeta. Orphan
* groups restored from wpdo_registry_meta are left out: their values live
* only in the flat table.
*
* @return string[]
*/
public static function backfillable_groups(): array {
return TMDO_Entity_Migration_Engine::backfillable_groups( self::ENTITY_TYPE );
}
/**
* All meta_keys registered for any post entity group.
*
@@ -546,8 +578,8 @@ final class TMDO_Post_Migration {
}
/**
* Map group name to the post_type it targets. The mapping is canonical
* to v2.9.1 group definitions in TMDO_Post_Fields.
* Map group name to the post_type it targets (see
* TMDO_Post_Fields::post_type_for_group()).
*
* Returns null for cross-post_type groups (e.g. 'wp_core').
*
@@ -555,23 +587,7 @@ final class TMDO_Post_Migration {
* @return string|null
*/
private static function group_post_type( string $group ): ?string {
switch ( $group ) {
case 'attachment':
return 'attachment';
case 'wc_product':
return 'product';
case 'hp_listing_core':
return 'hp_listing';
case 'hp_request_core':
return 'hp_request';
case 'hp_vendor_core':
return 'hp_vendor';
case 'nav_menu_item':
return 'nav_menu_item';
case 'wp_core':
default:
return null;
}
return TMDO_Post_Fields::post_type_for_group( $group );
}
/**
@@ -46,6 +46,10 @@ class TMDO_Phase_Cleanup extends TMDO_Migration_Phase_Base {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" );
}
$guarded = TMDO_Promotion_Guard::check_cleanup( $this->entity_type );
if ( $guarded ) {
throw new \RuntimeException( $guarded->get_error_message() );
}
if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) {
throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' );
}
File diff suppressed because it is too large Load Diff
@@ -310,7 +310,7 @@ class TMDO_Snapshot_Manager {
}
$table = $wpdb->prefix . self::TABLE_SLUG;
$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;
}
@@ -424,7 +424,7 @@ class TMDO_Snapshot_Manager {
return array();
}
$out = array();
$modules = array_merge( TMDO_Feature_Flags::HPCT_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
$modules = array_merge( TMDO_Feature_Flags::ADDON_MODULES, TMDO_Feature_Flags::ZONE_MODULES );
foreach ( $modules as $m ) {
$out[ $m ] = TMDO_Feature_Flags::get( $m );
}
+4 -2
View File
@@ -122,12 +122,14 @@ if ( ! trait_exists( 'TMDO_Anti_EAV_Aware' ) ) {
* @param string $entity_type 'post' | 'user' | 'term' | 'comment'.
* @param string $group_name Logical group name (e.g. 'tmos_vendor', 'tmos_audience').
* @param array<int,array<string,mixed>> $fields Field definitions.
* @param array<string,mixed> $args Optional: post_types (string[]) scopes a post group
* to those post types (see TMDO_Entity_Registry::register_group()).
*/
protected function register_entity_fields( string $entity_type, string $group_name, array $fields ): bool {
protected function register_entity_fields( string $entity_type, string $group_name, array $fields, array $args = array() ): bool {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return false;
}
return TMDO_Entity_Registry::register_group( $entity_type, $group_name, $fields );
return TMDO_Entity_Registry::register_group( $entity_type, $group_name, $fields, $args );
}
// ── Read/write convenience wrappers ──────────────────────────────────────
@@ -210,7 +210,15 @@ class TMDO_Zone_Archive {
global $wpdb;
$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(
$wpdb->prepare(
"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
WHERE p.post_status = 'trash'
AND p.post_modified_gmt < %s
AND NOT EXISTS ( SELECT 1 FROM `{$table}` a WHERE a.original_meta_id = pm.meta_id )
LIMIT %d",
$cutoff,
$limit
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! $rows ) {
return 0;
@@ -245,6 +255,21 @@ class TMDO_Zone_Archive {
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.
*
+23 -5
View File
@@ -25,6 +25,9 @@ if ( ! defined( 'ABSPATH' ) ) {
*/
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.
*
@@ -45,6 +48,21 @@ class TMDO_Zone_Cold {
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.
*
@@ -92,7 +110,7 @@ class TMDO_Zone_Cold {
*/
public static function get_blob( int $post_id, string $post_type ): array {
$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 );
if ( false !== $cached ) {
@@ -202,7 +220,7 @@ class TMDO_Zone_Cold {
);
// 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 {
global $wpdb;
$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 ───────────────────────────────────────────────────
@@ -264,7 +282,7 @@ class TMDO_Zone_Cold {
);
// 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
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
* error isolation are handled here. Hot and Cold are skipped when the
* post_type has no registered fields in that zone (table may not exist).
* post_type has no registered fields in that zone, or when the zone table
* was never created (fields are registered long before a module runs).
* Module state is not checked: a table left by a rolled-back module still
* gets its rows for deleted posts removed.
*
* @param int $post_id Post ID being deleted.
* @param string $post_type Post type slug.
@@ -113,7 +116,7 @@ class TMDO_Zone_Router {
public static function delete_post( int $post_id, string $post_type ): void {
$registry = TMDO_Schema_Registry::instance();
if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) {
if ( ! empty( $registry->get_hot_columns( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Hot::table( $post_type ) ) ) {
try {
TMDO_Zone_Hot::delete( $post_id, $post_type );
} catch ( \Throwable $e ) {
@@ -121,7 +124,7 @@ class TMDO_Zone_Router {
}
}
if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) {
if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Cold::table( $post_type ) ) ) {
try {
TMDO_Zone_Cold::delete( $post_id, $post_type );
} catch ( \Throwable $e ) {
+63 -1
View File
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
Requires at least: 6.0
Tested up to: 6.9
Requires PHP: 8.1
Stable tag: 1.0.0
Stable tag: 1.4.3
License: GPLv2 or later
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 ==
= 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 =
* 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
+372
View File
@@ -0,0 +1,372 @@
<?php
/**
* Scale-benchmark data seeder (dev only — `tests/` is excluded from release packages).
*
* Seeds a HivePress-shaped dataset into a dedicated `*_bench` database:
* vendors (users + hp_vendor posts) and listings (hp_listing posts), stored three
* ways so PR-005 can compare them at 100k vendors / 1M listings:
* 1. native — wp_posts / wp_postmeta / wp_users / wp_usermeta
* 2. entity — wpdo_post_hp_listing_core, wpdo_post_hp_vendor_core, wpdo_user_hp_user
* 3. hot — wpdo_hot_hp_listing (created by TMDO_Installer::create_hot_table())
*
* Values are derived from crc32( seed:index:key ), so every row is reproducible
* without sequential RNG state.
*
* @package TMDO\Tests\Bench
*/
declare(strict_types=1);
final class TMDO_Bench_Seeder {
public const MAX_ROWS = 2000000;
/** 2026-01-01 00:00:00 UTC. */
private const BASE_TS = 1767225600;
private const HOT_COLUMNS = array(
'hp_price' => 'decimal(12,2) NOT NULL DEFAULT 0',
'hp_featured' => 'tinyint(1) NOT NULL DEFAULT 0',
'hp_verified' => 'tinyint(1) NOT NULL DEFAULT 0',
'hp_vendor' => 'bigint(20) NOT NULL DEFAULT 0',
'hp_expired_time' => 'bigint(20) NOT NULL DEFAULT 0',
);
private ?mysqli $db;
private string $prefix;
private int $vendors;
private int $listings;
private int $seed;
private int $batch;
/** @var array<string, array{cols: string[], rows: string[]}> Pending multi-row INSERTs per table. */
private array $buffers = array();
/**
* Refuse to run against anything that is not a dedicated bench database.
*
* @throws InvalidArgumentException When the name does not end in `_bench`.
*/
public static function assert_bench_db( string $db_name ): void {
if ( ! preg_match( '/^[A-Za-z0-9_]+_bench$/', $db_name ) ) {
throw new InvalidArgumentException( "Refusing to seed '{$db_name}': database name must end in _bench." );
}
}
/**
* @throws InvalidArgumentException When a count is out of range.
*/
public static function assert_count( string $label, int $value ): void {
if ( $value < 1 || $value > self::MAX_ROWS ) {
throw new InvalidArgumentException( "--{$label} must be between 1 and " . self::MAX_ROWS . ", got {$value}." );
}
}
public function __construct( ?mysqli $db, string $prefix, int $vendors, int $listings, int $seed = 42, int $batch = 2000 ) {
$this->db = $db;
$this->prefix = $prefix;
$this->vendors = $vendors;
$this->listings = $listings;
$this->seed = $seed;
$this->batch = max( 1, $batch );
}
// ── Deterministic generators (pure — unit tested) ───────────────────────
private function det( int $i, string $key, int $mod ): int {
return crc32( "{$this->seed}:{$i}:{$key}" ) % $mod;
}
/** 1-based vendor index (1..vendors) that owns listing $i. */
public function listing_vendor( int $i ): int {
return $this->det( $i, 'vendor', $this->vendors ) + 1;
}
/** 90% publish, 5% draft, 3% pending, 2% private. */
public function listing_status( int $i ): string {
$r = $this->det( $i, 'status', 100 );
if ( $r < 90 ) {
return 'publish';
}
if ( $r < 95 ) {
return 'draft';
}
return $r < 98 ? 'pending' : 'private';
}
/**
* Meta a HivePress listing carries. Keys absent from the array are not stored natively.
*
* @return array<string, int|string>
*/
public function listing_meta( int $i, int $vendor_post_id ): array {
$featured = $this->det( $i, 'featured', 100 ) < 5 ? 1 : 0;
$meta = array(
'hp_price' => sprintf( '%d.00', 100 + $this->det( $i, 'price', 99901 ) ),
'hp_status' => $this->listing_status( $i ),
'hp_featured' => $featured,
'hp_verified' => $this->det( $i, 'verified', 100 ) < 30 ? 1 : 0,
'hp_vendor' => $vendor_post_id,
'hp_expired_time' => self::BASE_TS + $this->det( $i, 'expire', 365 ) * 86400,
'hp_view_count' => $this->det( $i, 'views', 5000 ),
'hp_rating' => sprintf( '%.2f', 1 + $this->det( $i, 'rating', 401 ) / 100 ),
'hp_rating_count' => $this->det( $i, 'rating_count', 200 ),
);
if ( $featured ) {
$meta['hp_featured_time'] = self::BASE_TS + $this->det( $i, 'featured_until', 90 ) * 86400;
}
return $meta;
}
/** @return array<string, int|string> */
public function vendor_meta( int $v ): array {
return array(
'hp_user' => $v,
'hp_verified' => $this->det( $v, 'v_verified', 100 ) < 40 ? 1 : 0,
'hp_hourly_rate' => sprintf( '%d.00', 20 + $this->det( $v, 'rate', 281 ) ),
'hp_rating' => sprintf( '%.2f', 1 + $this->det( $v, 'v_rating', 401 ) / 100 ),
'hp_rating_count' => $this->det( $v, 'v_rating_count', 500 ),
);
}
/** @return array<string, int|string> */
public function user_meta( int $v ): array {
return array(
'first_name' => 'Vendor',
'last_name' => (string) $v,
'nickname' => "vendor_{$v}",
'description' => "Bench vendor {$v}",
'hp_verified' => $this->det( $v, 'u_verified', 100 ) < 40 ? 1 : 0,
'hp_image' => (string) ( 100000 + $v ),
);
}
// ── Seeding ─────────────────────────────────────────────────────────────
/**
* Drop and rebuild every bench table, then load the dataset.
*
* @return array<string, int> Row counts per table.
*/
public function run(): array {
$this->exec( 'SET SESSION unique_checks = 0' );
$this->exec( 'SET SESSION foreign_key_checks = 0' );
$this->create_core_tables();
$this->create_flat_tables();
$this->seed_vendors();
$this->seed_listings();
$counts = array();
foreach ( array_keys( $this->table_names() ) as $table ) {
$counts[ $table ] = (int) $this->db->query( "SELECT COUNT(*) FROM `{$table}`" )->fetch_row()[0];
}
return $counts;
}
/** @return array<string, true> Every table this seeder owns. */
private function table_names(): array {
$p = $this->prefix;
return array_fill_keys(
array(
"{$p}users",
"{$p}usermeta",
"{$p}posts",
"{$p}postmeta",
"{$p}wpdo_user_hp_user",
"{$p}wpdo_post_hp_vendor_core",
"{$p}wpdo_post_hp_listing_core",
"{$p}wpdo_hot_hp_listing",
),
true
);
}
/**
* Core tables with the columns and indexes WordPress 6.x uses for these queries.
*/
private function create_core_tables(): void {
$p = $this->prefix;
foreach ( array( 'users', 'usermeta', 'posts', 'postmeta' ) as $t ) {
$this->exec( "DROP TABLE IF EXISTS `{$p}{$t}`" );
}
$this->exec(
"CREATE TABLE `{$p}users` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_login varchar(60) NOT NULL DEFAULT '',
user_nicename varchar(50) NOT NULL DEFAULT '',
user_email varchar(100) NOT NULL DEFAULT '',
user_registered datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
display_name varchar(250) NOT NULL DEFAULT '',
PRIMARY KEY (ID),
KEY user_login_key (user_login),
KEY user_nicename (user_nicename),
KEY user_email (user_email)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
);
$this->exec(
"CREATE TABLE `{$p}usermeta` (
umeta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (umeta_id),
KEY user_id (user_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
);
$this->exec(
"CREATE TABLE `{$p}posts` (
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_author bigint(20) unsigned NOT NULL DEFAULT 0,
post_date datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
post_title text NOT NULL,
post_status varchar(20) NOT NULL DEFAULT 'publish',
post_name varchar(200) NOT NULL DEFAULT '',
post_parent bigint(20) unsigned NOT NULL DEFAULT 0,
post_type varchar(20) NOT NULL DEFAULT 'post',
PRIMARY KEY (ID),
KEY post_name (post_name(191)),
KEY type_status_date (post_type,post_status,post_date,ID),
KEY post_parent (post_parent),
KEY post_author (post_author)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
);
$this->exec(
"CREATE TABLE `{$p}postmeta` (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) DEFAULT NULL,
meta_value longtext,
PRIMARY KEY (meta_id),
KEY post_id (post_id),
KEY meta_key (meta_key(191))
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
);
}
/**
* Flat tables are created by TMDO's own code so the bench measures the real schema.
*/
private function create_flat_tables(): void {
TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'post', new TMDO_Adapter_Post() );
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
TMDO_Post_Fields::register_entity_fields();
TMDO_Member_Fields::register();
// Schema_Manager keeps a schema hash per group in wpdo_registry_meta and skips
// creation when it matches, so a rebuilt table needs its hash row cleared too.
TMDO_Installer::install_v2_tables();
$registry_meta = $this->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
foreach ( array( array( 'post', 'hp_listing_core' ), array( 'post', 'hp_vendor_core' ), array( 'user', 'hp_user' ) ) as [ $type, $group ] ) {
$this->exec( 'DROP TABLE IF EXISTS `' . TMDO_Schema_Manager::get_table_name( $type, $group ) . '`' );
$this->exec( "DELETE FROM `{$registry_meta}` WHERE entity_type = '{$type}' AND group_name = '{$group}'" );
if ( ! TMDO_Schema_Manager::create_or_upgrade_table( $type, $group, TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) {
throw new RuntimeException( "Failed to create entity table {$type}/{$group}." );
}
}
$this->exec( "DROP TABLE IF EXISTS `{$this->prefix}wpdo_hot_hp_listing`" );
TMDO_Installer::create_hot_table( 'hp_listing', self::HOT_COLUMNS );
}
private function seed_vendors(): void {
$p = $this->prefix;
for ( $v = 1; $v <= $this->vendors; $v++ ) {
$this->push( "{$p}users", array( 'ID', 'user_login', 'user_nicename', 'user_email', 'user_registered', 'display_name' ), array( $v, "vendor_{$v}", "vendor-{$v}", "vendor{$v}@bench.invalid", '2026-01-01 00:00:00', "Vendor {$v}" ) );
$user_meta = $this->user_meta( $v );
foreach ( $user_meta as $key => $value ) {
$this->push( "{$p}usermeta", array( 'user_id', 'meta_key', 'meta_value' ), array( $v, $key, $value ) );
}
$this->push_entity( 'user', 'hp_user', $v, $user_meta );
// Vendor post IDs are 1..vendors; listing post IDs follow.
$this->push( "{$p}posts", array( 'ID', 'post_author', 'post_date', 'post_title', 'post_status', 'post_name', 'post_parent', 'post_type' ), array( $v, $v, '2026-01-01 00:00:00', "Vendor {$v}", 'publish', "vendor-{$v}", 0, 'hp_vendor' ) );
$vendor_meta = $this->vendor_meta( $v );
foreach ( $vendor_meta as $key => $value ) {
$this->push( "{$p}postmeta", array( 'post_id', 'meta_key', 'meta_value' ), array( $v, $key, $value ) );
}
$this->push_entity( 'post', 'hp_vendor_core', $v, $vendor_meta );
}
$this->flush_all();
}
private function seed_listings(): void {
$p = $this->prefix;
for ( $i = 1; $i <= $this->listings; $i++ ) {
$post_id = $this->vendors + $i;
$vendor_id = $this->listing_vendor( $i );
$meta = $this->listing_meta( $i, $vendor_id );
$date = gmdate( 'Y-m-d H:i:s', self::BASE_TS + $this->det( $i, 'date', 365 * 86400 ) );
$this->push( "{$p}posts", array( 'ID', 'post_author', 'post_date', 'post_title', 'post_status', 'post_name', 'post_parent', 'post_type' ), array( $post_id, $vendor_id, $date, "Listing {$i}", $meta['hp_status'], "listing-{$i}", $vendor_id, 'hp_listing' ) );
foreach ( $meta as $key => $value ) {
$this->push( "{$p}postmeta", array( 'post_id', 'meta_key', 'meta_value' ), array( $post_id, $key, $value ) );
}
$this->push_entity( 'post', 'hp_listing_core', $post_id, $meta );
$hot = array( 'post_id' => $post_id );
foreach ( array_keys( self::HOT_COLUMNS ) as $col ) {
$hot[ $col ] = $meta[ $col ] ?? 0;
}
$this->push( "{$p}wpdo_hot_hp_listing", array_keys( $hot ), array_values( $hot ) );
}
$this->flush_all();
}
/**
* One entity-table row per entity; registry keys without a value are stored as NULL.
*
* @param array<string, int|string> $meta
*/
private function push_entity( string $type, string $group, int $entity_id, array $meta ): void {
$id_col = TMDO_Entity_Registry::get_adapter( $type )->get_entity_id_column();
$cols = array( $id_col );
$vals = array( $entity_id );
foreach ( TMDO_Entity_Registry::get_group_keys( $type, $group ) as $key ) {
$cols[] = TMDO_Schema_Manager::sanitize_column_name( $key );
$vals[] = $meta[ $key ] ?? null;
}
$this->push( TMDO_Schema_Manager::get_table_name( $type, $group ), $cols, $vals );
}
/**
* @param string[] $cols
* @param array<int|string|null> $vals
*/
private function push( string $table, array $cols, array $vals ): void {
$escaped = array_map(
fn( $v ) => null === $v ? 'NULL' : ( is_int( $v ) ? (string) $v : "'" . $this->db->real_escape_string( (string) $v ) . "'" ),
$vals
);
$this->buffers[ $table ]['cols'] = $cols;
$this->buffers[ $table ]['rows'][] = '(' . implode( ',', $escaped ) . ')';
if ( count( $this->buffers[ $table ]['rows'] ) >= $this->batch ) {
$this->flush( $table );
}
}
private function flush( string $table ): void {
if ( empty( $this->buffers[ $table ]['rows'] ) ) {
return;
}
$cols = '`' . implode( '`,`', $this->buffers[ $table ]['cols'] ) . '`';
$this->exec( "INSERT INTO `{$table}` ({$cols}) VALUES " . implode( ',', $this->buffers[ $table ]['rows'] ) );
$this->buffers[ $table ]['rows'] = array();
}
private function flush_all(): void {
foreach ( array_keys( $this->buffers ) as $table ) {
$this->flush( $table );
}
}
private function exec( string $sql ): void {
if ( ! $this->db->query( $sql ) ) {
throw new RuntimeException( 'Bench seed query failed: ' . $this->db->error . ' — ' . substr( $sql, 0, 200 ) );
}
}
}
+263
View File
@@ -0,0 +1,263 @@
<?php
/**
* Scale benchmark: native postmeta vs hot table vs entity table.
*
* Runs against the dataset from tests/bench/seed.php:
* TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/run.php [--iterations=20] [--seed=7]
*
* SQL mirrors what the code under test emits: WP_Query meta_query (INNER JOIN
* per clause, CAST for numerics, SQL_CALC_FOUND_ROWS + FOUND_ROWS()),
* TMDO_Query_Router (hot) and TMDO_Post_Query_Router (entity, %s-quoted values).
* Writes run inside a transaction that is rolled back, so the dataset never changes.
*
* Output: a Markdown table on stdout (p50 / p95 / max in ms, result rows, index used).
*
* @package TMDO\Tests\Bench
*/
declare(strict_types=1);
if ( 'cli' !== PHP_SAPI ) {
exit( 1 );
}
require_once __DIR__ . '/class-tmdo-bench-seeder.php';
const P = 'wp_itest_';
$opts = getopt( '', array( 'iterations::', 'seed::' ) );
$iterations = max( 1, (int) ( $opts['iterations'] ?? 20 ) );
$warmup = 3;
$db_name = (string) getenv( 'TMDO_TEST_DB_NAME' );
try {
TMDO_Bench_Seeder::assert_bench_db( $db_name );
} catch ( InvalidArgumentException $e ) {
fwrite( STDERR, $e->getMessage() . PHP_EOL );
exit( 2 );
}
$db = new mysqli(
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
(string) getenv( 'TMDO_TEST_DB_PASS' ),
$db_name
);
$db->set_charset( 'utf8mb4' );
$db->query( 'SET SESSION max_statement_time = 30' ); // MariaDB: cap runaway native queries.
$vendors = (int) $db->query( 'SELECT COUNT(*) FROM `' . P . "posts` WHERE post_type = 'hp_vendor'" )->fetch_row()[0];
$listings = (int) $db->query( 'SELECT COUNT(*) FROM `' . P . "posts` WHERE post_type = 'hp_listing'" )->fetch_row()[0];
if ( 0 === $vendors || 0 === $listings ) {
fwrite( STDERR, "No bench data — run tests/bench/seed.php first.\n" );
exit( 3 );
}
mt_srand( (int) ( $opts['seed'] ?? 7 ) );
// ── SQL builders ──────────────────────────────────────────────────────────
/** WP_Query shape with one INNER JOIN per meta clause. */
function native_search( array $meta_where ): string {
$joins = '';
$where = array();
foreach ( $meta_where as $n => $cond ) {
$alias = 0 === $n ? P . 'postmeta' : "mt{$n}";
$joins .= ' INNER JOIN `' . P . 'postmeta`' . ( 0 === $n ? '' : " AS {$alias}" ) . " ON ( p.ID = {$alias}.post_id )";
$where[] = '( ' . sprintf( $cond, $alias ) . ' )';
}
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p{$joins} WHERE 1=1 AND ( " . implode( ' AND ', $where )
. " ) AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')) GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 0, 20";
}
/** TMDO router shape: one LEFT JOIN to a flat table plus flat WHERE clauses. */
function flat_search( string $table, string $alias, string $conditions ): string {
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p LEFT JOIN `" . P . "{$table}` AS `{$alias}` ON (p.ID = `{$alias}`.`post_id`)"
. " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 0, 20";
}
/** TMDO router shape with post_status / post_date copies (PR-029): INNER JOIN, no GROUP BY. */
function flat_copy_search( string $table, string $alias, string $conditions ): string {
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p INNER JOIN `" . P . "{$table}` AS `{$alias}` ON (p.ID = `{$alias}`.`post_id`)"
. " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} AND `{$alias}`.`post_status` IN ('publish') ORDER BY `{$alias}`.`post_date` DESC LIMIT 0, 20";
}
function random_ids( int $count, int $offset, int $max ): string {
$ids = array();
for ( $i = 0; $i < $count; $i++ ) {
$ids[] = $offset + mt_rand( 1, $max );
}
return implode( ',', $ids );
}
// ── Scenarios: label => [ variant => fn(): string[] (statements) ] ─────────
$price = static function (): array {
$lo = mt_rand( 100, 80000 );
return array( $lo, $lo + 20000 ); // ~20% of the 100..100000 range.
};
$scenarios = array(
'1. 價格區間搜尋(約 20%)' => array(
'native' => function () use ( $price ) {
[ $lo, $hi ] = $price();
return array( native_search( array( "%1\$s.meta_key = 'hp_price' AND CAST(%1\$s.meta_value AS DECIMAL(10,2)) BETWEEN '{$lo}' AND '{$hi}'" ) ), 'SELECT FOUND_ROWS()' );
},
'hot' => function () use ( $price ) {
[ $lo, $hi ] = $price();
return array( flat_search( 'wpdo_hot_hp_listing', 'h', " AND `h`.`hp_price` BETWEEN {$lo} AND {$hi}" ), 'SELECT FOUND_ROWS()' );
},
'entity' => function () use ( $price ) {
[ $lo, $hi ] = $price();
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' );
},
'entity+copy' => function () use ( $price ) {
[ $lo, $hi ] = $price();
return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' );
},
),
'2. 價格區間 + verified=1' => array(
'native' => function () use ( $price ) {
[ $lo, $hi ] = $price();
return array(
native_search(
array(
"%1\$s.meta_key = 'hp_price' AND CAST(%1\$s.meta_value AS DECIMAL(10,2)) BETWEEN '{$lo}' AND '{$hi}'",
"%1\$s.meta_key = 'hp_verified' AND %1\$s.meta_value = '1'",
)
),
'SELECT FOUND_ROWS()',
);
},
'hot' => function () use ( $price ) {
[ $lo, $hi ] = $price();
return array( flat_search( 'wpdo_hot_hp_listing', 'h', " AND `h`.`hp_price` BETWEEN {$lo} AND {$hi} AND `h`.`hp_verified` = 1" ), 'SELECT FOUND_ROWS()' );
},
'entity' => function () use ( $price ) {
[ $lo, $hi ] = $price();
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' );
},
'entity+copy' => function () use ( $price ) {
[ $lo, $hi ] = $price();
return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' );
},
),
'3. 某 vendor 名下 listing' => array(
'post_parent' => fn() => array( 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p WHERE p.post_parent = " . mt_rand( 1, $vendors ) . " AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')) ORDER BY p.post_date DESC LIMIT 0, 20", 'SELECT FOUND_ROWS()' ),
'native' => fn() => array( native_search( array( "%1\$s.meta_key = 'hp_vendor' AND %1\$s.meta_value = '" . mt_rand( 1, $vendors ) . "'" ) ), 'SELECT FOUND_ROWS()' ),
'hot' => fn() => array( flat_search( 'wpdo_hot_hp_listing', 'h', ' AND `h`.`hp_vendor` = ' . mt_rand( 1, $vendors ) ), 'SELECT FOUND_ROWS()' ),
'entity' => fn() => array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_vendor` = '" . mt_rand( 1, $vendors ) . "'" ), 'SELECT FOUND_ROWS()' ),
),
'4. 單筆 listing 讀全部 meta' => array(
'native' => fn() => array( 'SELECT post_id, meta_key, meta_value FROM `' . P . 'postmeta` WHERE post_id IN (' . random_ids( 1, $vendors, $listings ) . ') ORDER BY meta_id ASC' ),
'hot' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_hot_hp_listing` WHERE post_id = ' . random_ids( 1, $vendors, $listings ) ),
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_post_hp_listing_core` WHERE post_id = ' . random_ids( 1, $vendors, $listings ) ),
),
'5. 列表頁預載 20 筆 meta' => array(
'native' => fn() => array( 'SELECT post_id, meta_key, meta_value FROM `' . P . 'postmeta` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ') ORDER BY meta_id ASC' ),
'hot' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_hot_hp_listing` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ')' ),
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_post_hp_listing_core` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ')' ),
),
'6. 單一 vendor 讀使用者 meta' => array(
'native' => fn() => array( 'SELECT user_id, meta_key, meta_value FROM `' . P . 'usermeta` WHERE user_id IN (' . mt_rand( 1, $vendors ) . ') ORDER BY umeta_id ASC' ),
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_user_hp_user` WHERE user_id = ' . mt_rand( 1, $vendors ) ),
),
'7. 寫入:更新一筆 listing 價格' => array(
'native' => fn() => array( 'UPDATE `' . P . "postmeta` SET meta_value = '" . mt_rand( 100, 100000 ) . ".00' WHERE post_id = " . random_ids( 1, $vendors, $listings ) . " AND meta_key = 'hp_price'" ),
'entity' => fn() => array( 'UPDATE `' . P . "wpdo_post_hp_listing_core` SET hp_price = '" . mt_rand( 100, 100000 ) . "' WHERE post_id = " . random_ids( 1, $vendors, $listings ) ),
'dual_write' => function () use ( $vendors, $listings ) {
$id = random_ids( 1, $vendors, $listings );
$v = mt_rand( 100, 100000 );
return array(
'UPDATE `' . P . "postmeta` SET meta_value = '{$v}.00' WHERE post_id = {$id} AND meta_key = 'hp_price'",
'UPDATE `' . P . "wpdo_post_hp_listing_core` SET hp_price = '{$v}' WHERE post_id = {$id}",
);
},
),
);
// ── Run ───────────────────────────────────────────────────────────────────
/**
* @return array{ms: float, rows: int|string, timeout: bool}
*/
function run_statements( mysqli $db, array $statements ): array {
$is_write = str_starts_with( $statements[0], 'UPDATE' );
if ( $is_write ) {
$db->begin_transaction();
}
$rows = 0;
$timeout = false;
$start = hrtime( true );
foreach ( $statements as $n => $sql ) {
try {
$res = $db->query( $sql );
} catch ( mysqli_sql_exception $e ) {
if ( 1969 !== $e->getCode() ) { // ER_STATEMENT_TIMEOUT.
throw $e;
}
$timeout = true;
break;
}
if ( $res instanceof mysqli_result ) {
$rows = str_starts_with( $sql, 'SELECT FOUND_ROWS' ) ? (int) $res->fetch_row()[0] : ( 0 === $n ? $res->num_rows : $rows );
$res->free();
} elseif ( $is_write ) {
$rows = $db->affected_rows;
}
}
$ms = ( hrtime( true ) - $start ) / 1e6;
if ( $is_write ) {
$db->rollback();
}
return array( 'ms' => $ms, 'rows' => $rows, 'timeout' => $timeout );
}
function explain_keys( mysqli $db, string $sql ): string {
if ( ! str_starts_with( $sql, 'SELECT' ) ) {
return '—';
}
$keys = array();
$res = $db->query( 'EXPLAIN ' . $sql );
while ( $row = $res->fetch_assoc() ) {
$keys[] = $row['table'] . ':' . ( $row['key'] ?? 'ALL' );
}
return implode( ', ', $keys );
}
function percentile( array $sorted, float $p ): float {
return $sorted[ (int) min( count( $sorted ) - 1, floor( $p * count( $sorted ) ) ) ];
}
printf( "Dataset: %d vendors, %d listings — %d iterations (+%d warm-up) per variant\n\n", $vendors, $listings, $iterations, $warmup );
echo "| 情境 | 形式 | p50 ms | p95 ms | max ms | 結果筆數(中位) | 使用索引 |\n|---|---|---:|---:|---:|---:|---|\n";
foreach ( $scenarios as $label => $variants ) {
foreach ( $variants as $variant => $build ) {
$times = array();
$rows = array();
$timeouts = 0;
$keys = explain_keys( $db, $build()[0] );
for ( $i = 0; $i < $warmup + $iterations; $i++ ) {
$r = run_statements( $db, $build() );
if ( $i < $warmup ) {
continue;
}
$timeouts += (int) $r['timeout'];
$times[] = $r['ms'];
$rows[] = $r['rows'];
}
sort( $times );
sort( $rows );
printf(
"| %s | %s | %.2f | %.2f | %.2f | %s | %s |\n",
$label,
$variant . ( $timeouts ? " ⚠ {$timeouts}× timeout" : '' ),
percentile( $times, 0.5 ),
percentile( $times, 0.95 ),
end( $times ),
$rows[ intdiv( count( $rows ), 2 ) ],
$keys
);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
/**
* Seed the scale-benchmark dataset.
*
* Usage (DB credentials come from the same env vars as the integration suite):
* TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/seed.php --vendors=100000 --listings=1000000
*
* Options: --vendors=N (default 1000), --listings=N (default 10000), --seed=N (default 42),
* --batch=N rows per INSERT (default 2000).
*
* Drops and rebuilds its own wp_itest_* tables every run. Refuses any database
* whose name does not end in `_bench`.
*
* @package TMDO\Tests\Bench
*/
declare(strict_types=1);
if ( 'cli' !== PHP_SAPI ) {
exit( 1 );
}
require_once __DIR__ . '/class-tmdo-bench-seeder.php';
$opts = getopt( '', array( 'vendors::', 'listings::', 'seed::', 'batch::' ) );
$vendors = (int) ( $opts['vendors'] ?? 1000 );
$listings = (int) ( $opts['listings'] ?? 10000 );
$db_name = (string) getenv( 'TMDO_TEST_DB_NAME' );
try {
// Before the bootstrap: it runs CREATE DATABASE on whatever name it is given.
TMDO_Bench_Seeder::assert_bench_db( $db_name );
TMDO_Bench_Seeder::assert_count( 'vendors', $vendors );
TMDO_Bench_Seeder::assert_count( 'listings', $listings );
} catch ( InvalidArgumentException $e ) {
fwrite( STDERR, $e->getMessage() . PHP_EOL );
exit( 2 );
}
// Schema_Manager / Installer require ABSPATH . 'wp-admin/includes/upgrade.php'.
define( 'ABSPATH', __DIR__ . '/wp-stub/' );
require dirname( __DIR__ ) . '/integration/bootstrap.php';
$started = microtime( true );
$seeder = new TMDO_Bench_Seeder( $_mysqli, $wpdb->prefix, $vendors, $listings, (int) ( $opts['seed'] ?? 42 ), (int) ( $opts['batch'] ?? 2000 ) );
$counts = $seeder->run();
printf( "Seeded %s: %d vendors, %d listings in %.1fs\n", $db_name, $vendors, $listings, microtime( true ) - $started );
foreach ( $counts as $table => $rows ) {
printf( " %-40s %12d\n", $table, $rows );
}
@@ -0,0 +1,9 @@
<?php
/**
* Stand-in for WordPress's wp-admin/includes/upgrade.php during bench seeding.
*
* TMDO_Schema_Manager and TMDO_Installer require this file for dbDelta();
* tests/integration/bootstrap.php already provides dbDelta(), so it is empty.
*
* @package TMDO\Tests\Bench
*/
+30 -7
View File
@@ -180,7 +180,7 @@ if ( ! function_exists( 'do_action_ref_array' ) ) {
}
}
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' ) ) {
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' ) ) {
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' ) ) {
function wp_clear_scheduled_hook( string $hook ): int|false { return 0; }
}
@@ -230,8 +231,10 @@ if ( ! function_exists( 'esc_sql' ) ) {
return addslashes( $s );
}
}
if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } }
if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } }
if ( ! function_exists( '_doing_it_wrong' ) ) {
function _doing_it_wrong( string $fn, string $msg, string $ver ): void {}
function _doing_it_wrong( string $fn, string $msg, string $ver ): void { $GLOBALS['_doing_it_wrong_calls'][] = array( $fn, $msg ); }
}
if ( ! function_exists( 'get_option' ) ) {
function get_option( string $key, $default = false ) { return $GLOBALS['_wp_options'][ $key ] ?? $default; }
@@ -426,6 +429,18 @@ if ( ! function_exists( 'wp_cache_delete' ) ) {
function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; }
}
// Registered post types: name => publicly viewable (bool).
if ( ! function_exists( 'post_type_exists' ) ) {
function post_type_exists( string $post_type ): bool {
return isset( $GLOBALS['_wp_registered_post_types'][ $post_type ] );
}
}
if ( ! function_exists( 'is_post_type_viewable' ) ) {
function is_post_type_viewable( $post_type ): bool {
return ! empty( $GLOBALS['_wp_registered_post_types'][ (string) $post_type ] );
}
}
// ── Stub classes ───────────────────────────────────────────────────────────
if ( ! class_exists( 'WP_Query' ) ) {
@@ -434,7 +449,7 @@ if ( ! class_exists( 'WP_Query' ) ) {
public int $found_posts = 0;
public int $max_num_pages = 0;
private array $args = [];
public function __construct( array $args = [] ) { $this->args = $args; }
public function __construct( array $args = [] ) { $this->args = $args; $this->posts = $GLOBALS['_wp_query_posts'] ?? []; }
public function get( string $key, $default = '' ) { return $this->args[ $key ] ?? $default; }
public function set( string $key, $value ): void { $this->args[ $key ] = $value; }
}
@@ -492,10 +507,12 @@ if ( ! function_exists( 'register_rest_route' ) ) {
// WP_CLI stub (needed before loading CLI classes).
if ( ! class_exists( 'WP_CLI' ) ) {
class WP_CLI {
public static function log( string $msg ): void {}
public static function warning( string $msg ): void {}
public static function success( string $msg ): void {}
public static function error( string $msg ): void {}
// Output is kept in $GLOBALS['_wp_cli_output'] so CLI tests can assert it.
public static function log( string $msg ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
public static function line( string $msg = '' ): void { $GLOBALS['_wp_cli_output'][] = $msg; }
public static function warning( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Warning: ' . $msg; }
public static function success( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Success: ' . $msg; }
public static function error( string $msg ): void { $GLOBALS['_wp_cli_output'][] = 'Error: ' . $msg; }
public static function add_command( string $name, $class ): void {}
}
}
@@ -599,6 +616,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// Engine (v2.0.0).
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -607,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-entity-registry.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-migration-engine.php';
require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-entity-health.php';
require_once TMDO_PATH . 'includes/adapters/interface-entity-adapter.php';
require_once TMDO_PATH . 'includes/adapters/class-tmdo-adapter-post.php';
@@ -630,6 +649,10 @@ require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-cleanup.php
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.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-native-restorer.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
// Options Manager.
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Repair of text / textarea flat columns holding the literal "Array" (stored
* by the pre-1.1.0 type caster when the meta value was an array).
*
* Fixture (user entity, group al_prefs):
* user 1 prefs 'Array' native prefs serialized array → repairable
* user 2 loc 'Array' native loc 'Array' too → unrepairable, native polluted
* user 3 name 'Array' no native row → unrepairable
* user 4 prefs serialized array, loc 'Taipei' → untouched
*/
class ArrayLiteralRepairTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_al_prefs';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-cache-orchestrator.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/migration/class-tmdo-array-literal-repair.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, prefs TEXT DEFAULT NULL, loc VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 1, 'prefs' => 'Array' ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 2, 'loc' => 'Array' ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 3, 'name' => 'Array' ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 4, 'prefs' => $prefs, 'loc' => 'Taipei' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'prefs', 'meta_value' => $prefs ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 2, 'meta_key' => 'loc', 'meta_value' => 'Array' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'prefs', 'meta_value' => $prefs ) );
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group(
'user',
'al_prefs',
array(
array( 'key' => 'prefs', 'type' => 'textarea' ),
array( 'key' => 'loc', 'type' => 'text' ),
array( 'key' => 'name', 'type' => 'text' ),
)
);
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->usermeta, self::TABLE ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function flat( int $user_id, string $col ): ?string {
global $wpdb;
return $wpdb->get_var( $wpdb->prepare( "SELECT `{$col}` FROM `" . self::TABLE . '` WHERE user_id = %d', $user_id ) );
}
public function test_dry_run_reports_without_writing(): void {
$report = TMDO_Array_Literal_Repair::run( 'user', false );
$this->assertSame( 3, $report['found'] );
$this->assertSame( 1, $report['repairable'] );
$this->assertSame( 0, $report['repaired'] );
$this->assertSame( 'Array', $this->flat( 1, 'prefs' ) );
}
public function test_apply_repairs_from_native_and_lists_the_rest(): void {
$report = TMDO_Array_Literal_Repair::run( 'user', true );
$this->assertSame( 1, $report['repaired'] );
$this->assertSame( serialize( array( 'core' => array( 'welcomeGuide' => false ) ) ), $this->flat( 1, 'prefs' ) );
$this->assertSame( 'Array', $this->flat( 2, 'loc' ), 'native is "Array" too — nothing to repair from' );
$this->assertSame( 'Array', $this->flat( 3, 'name' ), 'no native row' );
$this->assertSame( 'Taipei', $this->flat( 4, 'loc' ) );
$unrepairable = array_map(
static function ( $r ) {
return $r['id'] . ':' . $r['key'] . ':' . $r['reason'];
},
$report['unrepairable']
);
sort( $unrepairable );
$this->assertSame( array( '2:loc:native_is_array_literal', '3:name:no_native_row' ), $unrepairable );
$this->assertSame( array( array( 'group' => 'al_prefs', 'key' => 'loc', 'id' => 2 ) ), $report['native_polluted'] );
}
public function test_second_run_finds_only_the_unrepairable_rows(): void {
TMDO_Array_Literal_Repair::run( 'user', true );
$report = TMDO_Array_Literal_Repair::run( 'user', true );
$this->assertSame( 2, $report['found'] );
$this->assertSame( 0, $report['repaired'] );
}
}
@@ -0,0 +1,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'] );
}
}
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Composite indexes declared on a group reach its existing flat table.
*
* The table already exists with data (its schema hash is unchanged), so the
* index is added in place rather than through a table rebuild.
*/
class GroupCompositeIndexTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_post_ci_core';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/adapters/class-tmdo-adapter-post.php',
) as $file ) {
require_once $base . $file;
}
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, ci_flag BIGINT(20) DEFAULT NULL, ci_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' );
$GLOBALS['_wp_options'] = array();
// 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->setAccessible( true );
$ref->setValue( null, array() );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group(
'post',
'ci_core',
array(
array( 'key' => 'ci_flag', 'type' => 'integer' ),
array( 'key' => 'ci_price', 'type' => 'decimal' ),
),
array( 'indexes' => array( 'flag_price' => array( 'ci_flag', 'ci_price' ) ) )
);
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
WPDO_Entity_Registry::init();
$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 */
private function indexes(): array {
global $wpdb;
$out = array();
foreach ( (array) $wpdb->get_results( 'SHOW INDEX FROM `' . self::TABLE . '`', ARRAY_A ) as $row ) {
$out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name'];
}
return $out;
}
public function test_missing_composite_index_is_added_to_the_existing_table(): void {
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertSame( array( 'ci_flag', 'ci_price' ), $this->indexes()['cidx_flag_price'] ?? null );
global $wpdb;
$this->assertSame( '2', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' ), 'data untouched' );
}
public function test_applied_definition_is_remembered_so_later_requests_skip_the_check(): void {
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
global $wpdb;
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` DROP INDEX `cidx_flag_price`' );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'same definition → no SHOW INDEX / ALTER' );
$this->assertArrayHasKey( 'post/ci_core', get_option( TMDO_Schema_Manager::INDEXES_OPTION ) );
}
public function test_group_without_indexes_is_left_alone(): void {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group( 'post', 'ci_core', array( array( 'key' => 'ci_flag', 'type' => 'integer' ) ) );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertSame( array( 'PRIMARY', 'uk_entity' ), array_keys( $this->indexes() ) );
$this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ) );
}
}
+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' );
}
public function test_update_writes_flat_and_returns_null_in_dual_write_mode(): void {
/**
* dual_write: the pre-filter only lets WP write native; flat is synced
* from native afterwards (updated_/added_ actions → sync_from_native).
*/
public function test_update_leaves_flat_to_the_native_sync_in_dual_write_mode(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
$GLOBALS['_wp_current_filter'] = 'update_user_metadata';
$result = WPDO_Hook_Bus::intercept_update( null, 3, 'first_name', 'Carol', '' );
$this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) );
$this->assertNull( $result, 'dual_write must return null so WP continues to write EAV' );
$this->assertNull( $this->read_flat_first_name( 3 ), 'no flat write before native is written' );
$this->with_usermeta(
function () use ( $wpdb ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 3, 'meta_key' => 'first_name', 'meta_value' => 'Carol' ) );
WPDO_Hook_Bus::sync_from_native( 'user', 3, 'first_name', 'Carol', 'update' );
$this->assertSame( 'Carol', $this->read_flat_first_name( 3 ) );
}
);
}
/**
* 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 {
@@ -268,6 +367,30 @@ class HookBusIntegrationTest extends TestCase {
$this->assertNull( $result, 'dual_write does not read from flat — must return $check' );
}
/**
* shadow_read serves native meta: update_metadata() reads the old value
* through this filter, and serving the just-written flat value made WP
* skip the native write (dev36, 2026-09-26). Flat is only compared.
*/
public function test_get_serves_native_in_shadow_read_mode_even_with_a_flat_value(): void {
global $wpdb;
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 12, 'first_name' => 'Flat' ) );
self::set_user_mode( WPDO_Mode_Manager::MODE_SHADOW_READ );
foreach ( array( 0.0, 1.0 ) as $rate ) {
$GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] = array(
static function () use ( $rate ) {
return $rate;
},
);
$result = WPDO_Hook_Bus::intercept_get( null, 12, 'first_name', true, 'user' );
$this->assertNull( $result, "shadow_read must return \$check (sample rate {$rate})" );
}
unset( $GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] );
}
public function test_get_returns_empty_string_when_column_is_null(): void {
global $wpdb;
$wpdb->query(
@@ -68,6 +68,50 @@ class InstallerCleanupTest extends TestCase {
$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 {
global $wpdb;
+13
View File
@@ -111,6 +111,19 @@ class InstallerV2Test extends TestCase {
}
}
public function test_install_does_not_enable_entity_bridge(): void {
delete_option( 'wpdo_hook_bus_enabled' );
delete_option( 'wpdo_bridge_modes' );
delete_option( 'wpdo_entity_bridge_autoactivated_v2' );
// Skip the crypto step: it queries $wpdb->options, which InstallerCleanupTest leaves pointing at its own prefix.
update_option( 'wpdo_crypto_migrated_v2', '1' );
TMDO_Installer::install();
$this->assertFalse( get_option( 'wpdo_hook_bus_enabled' ), 'install() must not switch the Hook Bus on' );
$this->assertFalse( get_option( 'wpdo_bridge_modes' ), 'install() must not write bridge modes' );
}
public static function tearDownAfterClass(): void {
// Leave v2 tables in place for subsequent tests / dev convenience.
// Cleanup happens via wp wpdo cleanup-uae-tables --confirm in real upgrades.
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Post write-back follows the same routing reads use.
*
* Listing 101 has `shared_key` in both group tables: 'L' in its own
* (hp_listing-scoped) group, a stray 'V' in the vendor group — the legacy
* owner, registered last. Whichever table reads use must be the one copied.
*/
class NativeRestorerPostTest extends TestCase {
private const LISTING = 'wp_itest_wpdo_post_nr_listing';
private const VENDOR = 'wp_itest_wpdo_post_nr_vendor';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-cache-orchestrator.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/migration/class-tmdo-native-restorer.php',
) as $file ) {
require_once $base . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', 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), KEY post_id (post_id), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" );
foreach ( array( self::LISTING, self::VENDOR ) as $table ) {
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT '2020-01-01 00:00:00', PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB" );
}
$wpdb->insert( $wpdb->posts, array( 'ID' => 101, 'post_type' => 'hp_listing' ) );
$wpdb->insert( self::LISTING, array( 'post_id' => 101, 'shared_key' => 'L' ) );
$wpdb->insert( self::VENDOR, array( 'post_id' => 101, 'shared_key' => 'V' ) );
$GLOBALS['_wp_cache'] = array();
$GLOBALS['_wp_transients'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group( 'post', 'nr_listing', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
WPDO_Entity_Registry::register_group( 'post', 'nr_vendor', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) );
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->posts, $wpdb->postmeta, self::LISTING, self::VENDOR ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function native_value(): array {
global $wpdb;
return $wpdb->get_col( "SELECT meta_value FROM `{$wpdb->postmeta}` WHERE post_id = 101 AND meta_key = 'shared_key'" );
}
public function test_scoped_routing_writes_back_the_post_types_own_group(): void {
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '1' );
TMDO_Native_Restorer::run( 'post' );
$this->assertSame( array( 'L' ), $this->native_value() );
}
public function test_legacy_routing_writes_back_the_legacy_owner(): void {
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '0' );
TMDO_Native_Restorer::run( 'post' );
$this->assertSame( array( 'V' ), $this->native_value() );
}
}
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Retiring one entity group: write its flat values back to native meta, then
* forget it so orphan restore never routes its keys to the flat table again.
*
* Fixture (user, aeav_only): group rt_a (token, ips) and rt_b (other).
* rt_a user 1: token 't1', ips '["1.1.1.1"]' user 2: token 't2', ips NULL
* rt_b user 1: other 'o1'
*/
class NativeRestorerRetireTest extends TestCase {
private const A = 'wp_itest_wpdo_user_rt_a';
private const B = 'wp_itest_wpdo_user_rt_b';
private const META = 'wp_itest_wpdo_registry_meta';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-cache-orchestrator.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/migration/class-tmdo-native-restorer.php',
) as $file ) {
require_once $base . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::A . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, token VARCHAR(255) DEFAULT NULL, ips LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::B . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, other VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'INSERT INTO `' . self::A . "` (user_id, token, ips) VALUES (1, 't1', '[\"1.1.1.1\"]'), (2, 't2', NULL)" );
$wpdb->insert( self::B, array( 'user_id' => 1, 'other' => 'o1' ) );
$wpdb->query( 'UPDATE `' . self::A . "` SET updated_at = '2020-01-01 00:00:00'" );
$wpdb->query( 'UPDATE `' . self::B . "` SET updated_at = '2020-01-01 00:00:00'" );
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'aeav_only' ) );
$GLOBALS['_wp_cache'] = array();
$GLOBALS['_wp_transients'] = array();
TMDO_Mode_Manager::reset_cache();
self::register( true );
$wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) );
$wpdb->insert(
self::META,
array(
'entity_type' => 'user',
'group_name' => 'rt_a',
'schema_hash' => 'itest',
'field_definitions' => wp_json_encode( WPDO_Entity_Registry::get_group_fields( 'user', 'rt_a' ) ),
)
);
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
TMDO_Mode_Manager::reset_cache();
}
/** Fresh registry; rt_b always registered, rt_a only while its provider is active. */
private static function register( bool $with_a ): void {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
if ( $with_a ) {
WPDO_Entity_Registry::register_group(
'user',
'rt_a',
array(
array( 'key' => 'token', 'type' => 'text' ),
array( 'key' => 'ips', 'type' => 'textarea' ),
)
);
}
WPDO_Entity_Registry::register_group( 'user', 'rt_b', array( array( 'key' => 'other', 'type' => 'text' ) ) );
}
private static function drop(): void {
global $wpdb;
foreach ( (array) $wpdb->get_col( "SHOW TABLES LIKE 'wp\\_itest\\_wpdo\\_user\\_rt\\_%'" ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" );
$wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) );
}
private function native( int $user_id, string $key ): array {
global $wpdb;
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$wpdb->usermeta}` WHERE user_id = %d AND meta_key = %s", $user_id, $key ) );
}
public function test_group_option_writes_back_only_that_group(): void {
$report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
$this->assertSame( array( 'rt_a' ), array_keys( $report['groups'] ) );
$this->assertSame( array( 't1' ), $this->native( 1, 'token' ) );
$this->assertSame( array( '["1.1.1.1"]' ), $this->native( 1, 'ips' ) );
$this->assertSame( array(), $this->native( 1, 'other' ), 'rt_b untouched' );
}
public function test_retire_refuses_a_group_that_is_still_registered(): void {
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
$this->assertSame( 'registered', TMDO_Native_Restorer::retire( 'user', 'rt_a' )['status'] );
global $wpdb;
$this->assertSame( self::A, $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) );
}
public function test_retire_refuses_when_native_meta_differs_from_the_flat_table(): void {
self::register( false );
$result = TMDO_Native_Restorer::retire( 'user', 'rt_a' );
$this->assertSame( 'mismatch', $result['status'] );
$this->assertSame( 3, $result['mismatches'], 't1, ["1.1.1.1"], t2 are not native yet' );
}
public function test_empty_string_values_count_as_written_back(): void {
global $wpdb;
$wpdb->query( 'UPDATE `' . self::A . "` SET ips = '' WHERE user_id = 2" );
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
self::register( false );
$this->assertSame( 'retired', TMDO_Native_Restorer::retire( 'user', 'rt_a' )['status'], "wpdb::get_col() reads '' as null" );
}
public function test_retire_forgets_the_group_and_keeps_its_table_as_a_backup(): void {
global $wpdb;
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
self::register( false );
$result = TMDO_Native_Restorer::retire( 'user', 'rt_a' );
$this->assertSame( 'retired', $result['status'] );
$this->assertMatchesRegularExpression( '/^' . self::A . '_retired_\d{8}$/', $result['archived_table'] );
$this->assertSame( $result['archived_table'], $wpdb->get_var( "SHOW TABLES LIKE '" . $result['archived_table'] . "'" ) );
$this->assertNull( $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) );
$this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::META . "` WHERE entity_type = 'user' AND group_name = 'rt_a'" ) );
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups(), 'orphan restore no longer routes the keys to the flat table' );
}
}
+215
View File
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Writing flat-table values back to native meta so an aeav_only entity can
* leave aeav_only.
*
* Rule: after the write-back, native meta returns what reads return today.
* Fixture (user entity, both groups hold `nickname`; the legacy owner is
* rn_extra, registered last):
* rn_profile user 1: nickname 'old', level 3, tags ["a","b"]
* user 2: nickname NULL, level 5, tags NULL
* rn_extra user 1: nickname 'live'
* usermeta user 1 level '1' (stale), user 2 tags 'stale'
*/
class NativeRestorerTest extends TestCase {
private const PROFILE = 'wp_itest_wpdo_user_rn_profile';
private const EXTRA = 'wp_itest_wpdo_user_rn_extra';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-cache-orchestrator.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/migration/class-tmdo-native-restorer.php',
) as $file ) {
require_once $base . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id), KEY meta_key (meta_key(191)) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::PROFILE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, nickname VARCHAR(255) DEFAULT NULL, level BIGINT(20) DEFAULT NULL, tags LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::EXTRA . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, nickname VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'INSERT INTO `' . self::PROFILE . "` (user_id, nickname, level, tags) VALUES (1, 'old', 3, '[\"a\",\"b\"]'), (2, NULL, 5, NULL)" );
$wpdb->insert( self::EXTRA, array( 'user_id' => 1, 'nickname' => 'live' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'level', 'meta_value' => '1' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 2, 'meta_key' => 'tags', 'meta_value' => 'stale' ) );
// Historical rows: older than any run, so the catch-up pass leaves them alone.
$wpdb->query( 'UPDATE `' . self::PROFILE . "` SET updated_at = '2020-01-01 00:00:00'" );
$wpdb->query( 'UPDATE `' . self::EXTRA . "` SET updated_at = '2020-01-01 00:00:00'" );
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'aeav_only' ) );
$GLOBALS['_wp_cache'] = array();
$GLOBALS['_wp_transients'] = array();
TMDO_Mode_Manager::reset_cache();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group(
'user',
'rn_profile',
array(
array( 'key' => 'nickname', 'type' => 'text' ),
array( 'key' => 'level', 'type' => 'integer' ),
array( 'key' => 'tags', 'type' => 'json' ),
)
);
WPDO_Entity_Registry::register_group( 'user', 'rn_extra', array( array( 'key' => 'nickname', 'type' => 'text' ) ) );
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
TMDO_Mode_Manager::reset_cache();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->usermeta, self::PROFILE, self::EXTRA ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
/** @return string[] meta_value rows for (user, key). */
private function native( int $user_id, string $key ): array {
global $wpdb;
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$wpdb->usermeta}` WHERE user_id = %d AND meta_key = %s", $user_id, $key ) );
}
public function test_dry_run_counts_without_writing(): void {
$report = TMDO_Native_Restorer::run( 'user', true );
$this->assertSame( 2, $report['groups']['rn_profile']['rows'] );
$this->assertSame( 1, $report['groups']['rn_extra']['rows'] );
$this->assertSame( array( '1' ), $this->native( 1, 'level' ) );
$this->assertFalse( $report['demoted'] );
}
public function test_native_meta_ends_up_matching_current_reads(): void {
TMDO_Native_Restorer::run( 'user', false, 1 );
$this->assertSame( array( 'live' ), $this->native( 1, 'nickname' ), 'the group reads use (rn_extra) wins' );
$this->assertSame( array( '3' ), $this->native( 1, 'level' ), 'stale native value replaced' );
$this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ), 'json stored the way WP stores arrays' );
$this->assertSame( array( '5' ), $this->native( 2, 'level' ) );
$this->assertSame( array(), $this->native( 2, 'tags' ), 'NULL flat value removes the stale native row' );
$this->assertSame( array(), $this->native( 2, 'nickname' ) );
}
public function test_completion_clears_progress_and_demote_leaves_aeav_only(): void {
$report = TMDO_Native_Restorer::run( 'user', false, 500, true );
$this->assertTrue( $report['demoted'] );
$this->assertSame( 'dual_write', TMDO_Mode_Manager::get( 'user' ) );
$this->assertFalse( get_option( TMDO_Native_Restorer::CURSOR_OPTION ) );
$this->assertFalse( get_option( TMDO_Native_Restorer::STARTED_OPTION ) );
}
public function test_resumes_after_the_stored_cursor(): void {
update_option( TMDO_Native_Restorer::CURSOR_OPTION, array( 'user/rn_profile' => 1 ) );
TMDO_Native_Restorer::run( 'user', false );
$this->assertSame( array( '1' ), $this->native( 1, 'level' ), 'user 1 was done before the interruption' );
$this->assertSame( array( '5' ), $this->native( 2, 'level' ) );
}
public function test_catch_up_copies_rows_written_during_the_run(): void {
global $wpdb;
$wpdb->query( 'UPDATE `' . self::PROFILE . "` SET level = 9, updated_at = NOW() + INTERVAL 1 HOUR WHERE user_id = 1" );
update_option( TMDO_Native_Restorer::CURSOR_OPTION, array( 'user/rn_profile' => 1 ) );
TMDO_Native_Restorer::run( 'user', false );
$this->assertSame( array( '9' ), $this->native( 1, 'level' ) );
}
private function set_user_mode( string $mode ): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = array( 'user' => $mode );
TMDO_Mode_Manager::reset_cache();
}
/**
* Native is the source of truth outside aeav_only (e.g. WooCommerce writes
* stock with direct SQL): the default policy there only fills gaps.
*/
public function test_native_authoritative_modes_only_fill_missing_rows(): void {
$this->set_user_mode( 'dual_write' );
$report = TMDO_Native_Restorer::run( 'user', false );
$this->assertSame( array( '1' ), $this->native( 1, 'level' ), 'differing native value kept' );
$this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ), 'missing native row filled' );
$this->assertSame( array( '5' ), $this->native( 2, 'level' ) );
$this->assertSame( array( 'stale' ), $this->native( 2, 'tags' ), 'NULL flat value does not delete native here' );
$this->assertSame( 'only_missing', $report['policy'] );
$this->assertSame( 2, $report['groups']['rn_profile']['missing'] );
$this->assertSame( 2, $report['groups']['rn_profile']['differs'] );
}
public function test_overwrite_policy_replaces_native_in_any_mode(): void {
$this->set_user_mode( 'dual_write' );
TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'overwrite' );
$this->assertSame( array( '3' ), $this->native( 1, 'level' ) );
$this->assertSame( array(), $this->native( 2, 'tags' ) );
}
public function test_only_missing_policy_keeps_native_in_aeav_only(): void {
TMDO_Native_Restorer::run( 'user', false, 500, false, '', 'only_missing' );
$this->assertSame( array( '1' ), $this->native( 1, 'level' ) );
$this->assertSame( array( serialize( array( 'a', 'b' ) ) ), $this->native( 1, 'tags' ) );
}
public function test_dry_run_classifies_without_writing(): void {
$report = TMDO_Native_Restorer::run( 'user', true );
$this->assertSame( 'overwrite', $report['policy'], 'aeav_only: flat is the source of truth' );
$this->assertSame( 2, $report['groups']['rn_profile']['missing'] );
$this->assertSame( 2, $report['groups']['rn_profile']['differs'] );
$this->assertSame( 0, $report['groups']['rn_profile']['same'] );
$this->assertSame( array( '1' ), $this->native( 1, 'level' ) );
}
/**
* A flat column holding its field default for an entity that never had
* the key (e.g. activity.session_count = 0) is not written back.
*/
public function test_defaults_are_not_invented_as_native_rows(): void {
global $wpdb;
$table = 'wp_itest_wpdo_user_rn_counts';
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
$wpdb->query( "CREATE TABLE `{$table}` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, visits BIGINT(20) DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB" );
$wpdb->query( "INSERT INTO `{$table}` (user_id, visits, updated_at) VALUES (1, 0, '2020-01-01'), (2, 7, '2020-01-01')" );
WPDO_Entity_Registry::register_group( 'user', 'rn_counts', array( array( 'key' => 'visits', 'type' => 'integer', 'default' => 0 ) ) );
WPDO_Schema_Manager::flush_table_exists_cache();
try {
$report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rn_counts' );
$this->assertSame( array(), $this->native( 1, 'visits' ), 'default 0 not invented' );
$this->assertSame( array( '7' ), $this->native( 2, 'visits' ) );
$this->assertSame( 1, $report['groups']['rn_counts']['default_skipped'] );
} finally {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
}
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Orphaned flat-table groups stay readable.
*
* Scenario: a plugin registered an entity group, its flat table was created
* (definition persisted in wpdo_registry_meta), data landed there, and then
* the plugin went away. In aeav_only mode the values exist only in the flat
* table, so without the group registration reads come back empty.
*/
class OrphanGroupRestoreTest extends TestCase {
private const GROUP = 'orphan_itest';
private const TABLE = 'wp_itest_wpdo_user_orphan_itest';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-cache-orchestrator.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/engine/class-tmdo-hook-bus.php',
) as $file ) {
require_once $base . $file;
}
WPDO_Installer::install_v2_tables();
global $wpdb;
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `{$wpdb->usermeta}` (
umeta_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
meta_key VARCHAR(255) DEFAULT NULL,
meta_value LONGTEXT,
PRIMARY KEY (umeta_id),
KEY meta_key (meta_key(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
protected function setUp(): void {
global $wpdb;
self::drop_fixture();
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cache'] = array();
$GLOBALS['_wp_transients'] = array();
self::reset_statics();
// A provider registered the group earlier: its flat table exists and the
// normalized definition was persisted to registry_meta (what
// Schema_Manager::create_or_upgrade_table() leaves behind — built by hand
// here because that method needs the real wp-admin upgrade.php).
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', self::GROUP, array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) );
$wpdb->query(
'CREATE TABLE `' . self::TABLE . '` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`user_id` BIGINT(20) NOT NULL,
`orphan_note` VARCHAR(255) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_entity` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
$wpdb->insert(
$wpdb->prefix . 'wpdo_registry_meta',
array(
'entity_type' => 'user',
'group_name' => self::GROUP,
'schema_hash' => 'itest',
'field_definitions' => wp_json_encode( WPDO_Entity_Registry::get_group_fields( 'user', self::GROUP ) ),
)
);
$wpdb->insert( self::TABLE, array( 'user_id' => 77, 'orphan_note' => 'kept' ) );
// The provider goes away: next request starts with an empty registry.
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
self::reset_statics();
}
protected function tearDown(): void {
self::drop_fixture();
WPDO_Entity_Registry::init();
self::set_modes( null );
}
public function test_orphaned_group_is_unreadable_before_restore(): void {
self::set_modes( 'aeav_only' );
$this->assertNull( WPDO_Hook_Bus::intercept_get( null, 77, 'orphan_note', true, 'user' ) );
}
public function test_restore_makes_aeav_only_data_readable_again(): void {
self::set_modes( 'aeav_only' );
$this->assertSame( 1, WPDO_Schema_Manager::restore_orphaned_groups() );
$this->assertSame( array( 'kept' ), WPDO_Hook_Bus::intercept_get( null, 77, 'orphan_note', true, 'user' ) );
$this->assertSame( array(), WPDO_Entity_Registry::get_pending_schemas(), 'Restoring must not queue DDL.' );
}
public function test_restore_only_applies_to_aeav_only(): void {
// dual_write / shadow_read still write native meta, so an orphaned key
// keeps reading correctly from there — nothing to restore.
foreach ( array( 'disabled', 'dual_write', 'shadow_read' ) as $mode ) {
self::set_modes( $mode );
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups(), $mode );
$this->assertNull( WPDO_Entity_Registry::get_field( 'user', 'orphan_note' ), $mode );
}
}
public function test_key_with_native_meta_rows_stays_on_native(): void {
global $wpdb;
self::set_modes( 'aeav_only' );
// Written to native meta while the group was unregistered — newer than the flat copy.
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 5, 'meta_key' => 'orphan_note', 'meta_value' => 'native' ) );
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() );
$this->assertNull( WPDO_Hook_Bus::intercept_get( null, 5, 'orphan_note', true, 'user' ), 'Native meta must keep serving this key.' );
$this->assertSame( array( 'orphan_note' ), WPDO_Entity_Registry::get_restored_groups()[0]['native'] );
}
public function test_native_meta_check_is_cached(): void {
self::set_modes( 'aeav_only' );
WPDO_Schema_Manager::restore_orphaned_groups();
$this->assertSame( array( 'user:orphan_note' => false ), get_transient( 'tmdo_orphan_native_keys' ) );
}
public function test_restore_skips_groups_whose_table_is_gone(): void {
global $wpdb;
self::set_modes( 'aeav_only' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
self::reset_statics();
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() );
}
public function test_registered_group_is_left_alone(): void {
self::set_modes( 'aeav_only' );
WPDO_Entity_Registry::register_group( 'user', self::GROUP, array( array( 'key' => 'orphan_note', 'type' => 'text' ) ) );
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups() );
$this->assertSame( array(), WPDO_Entity_Registry::get_restored_groups() );
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static function drop_fixture(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => self::GROUP ) );
$wpdb->delete( $wpdb->usermeta, array( 'meta_key' => 'orphan_note' ) );
}
/** Clear Schema_Manager's table_exists cache and the Cache_Orchestrator L1. */
private static function reset_statics(): void {
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
$l1 = new ReflectionProperty( WPDO_Cache_Orchestrator::class, 'l1_cache' );
$l1->setAccessible( true );
$l1->setValue( null, array() );
}
/** Inject the user mode straight into Mode_Manager's cache (null = reset). */
private static function set_modes( ?string $user_mode ): void {
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$ref->setAccessible( true );
$ref->setValue(
null,
null === $user_mode ? null : array(
'user' => $user_mode,
'post' => 'disabled',
'term' => 'disabled',
'comment' => 'disabled',
)
);
}
}
@@ -215,6 +215,38 @@ class PostBackfillJsonTest extends TestCase {
$this->assertSame( 0, $result['errors'] ?? -1 );
}
// ── backfill_group() / backfillable_groups() (2.2 PR-067) ───────────────
public function test_backfill_group_fills_json_columns_instead_of_skipping_them(): void {
$this->seed_attachment( 1, array( 'width' => 640 ), 'Alt text' );
$result = WPDO_Post_Migration::backfill_group( 'attachment' );
$this->assertSame( 'attachment', $result['group'] );
global $wpdb;
$row = $wpdb->get_row( 'SELECT _wp_attachment_metadata, _wp_attachment_image_alt FROM `' . self::FLAT . '` WHERE post_id = 1', ARRAY_A );
$this->assertSame( array( 'width' => 640 ), json_decode( (string) $row['_wp_attachment_metadata'], true ), 'json field must not be left NULL by the bulk pivot' );
$this->assertSame( 'Alt text', $row['_wp_attachment_image_alt'] );
}
public function test_backfillable_groups_are_the_registered_post_groups(): void {
WPDO_Entity_Registry::register_group( 'post', 'itest_extra', array( array( 'key' => 'itest_extra_key', 'type' => 'text' ) ) );
$groups = WPDO_Post_Migration::backfillable_groups();
$this->assertSame( WPDO_Entity_Registry::get_groups_for_type( 'post' ), $groups );
$this->assertContains( 'itest_extra', $groups, 'a group registered by an add-on must be offered for backfill' );
}
public function test_restored_orphan_groups_are_never_backfilled(): void {
WPDO_Entity_Registry::restore_group( 'post', 'itest_orphan', array( array( 'key' => 'itest_orphan_key', 'type' => 'text' ) ) );
$this->assertContains( 'itest_orphan', WPDO_Entity_Registry::get_groups_for_type( 'post' ) );
$this->assertNotContains( 'itest_orphan', WPDO_Post_Migration::backfillable_groups(), 'orphan data lives only in the flat table' );
$this->expectException( InvalidArgumentException::class );
WPDO_Post_Migration::backfill_group( 'itest_orphan' );
}
public function test_returns_error_for_unknown_group(): void {
$result = WPDO_Post_Migration::backfill_group_json( 'bogus_group' );
@@ -129,6 +129,7 @@ class PostEntityLifecycleTest extends TestCase {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
tmdo_test_register_wc_product_group();
}
public static function tearDownAfterClass(): void {
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Moving values written under legacy routing into their post type's group.
*
* Fixture (legacy owner of `shared_key` = rc_vendor, the last group registered):
* listing 101: vendor table 'live' | listing table 'backfill' (+ price 5)
* listing 102: vendor table 'only-in-vendor' | no listing row
* listing 103: no vendor row | listing table 'keep'
* listing 104: vendor row with NULL | listing table 'backfill-104'
* vendor 202: vendor table 'vendor own value'
*
* Reads before the switch returned the vendor-table value for 101/102, so
* those must win; 103/104 were never written after the backfill, so the
* listing value stays.
*/
class PostGroupReconcilerTest extends TestCase {
private const LISTING = 'wp_itest_wpdo_post_rc_listing';
private const VENDOR = 'wp_itest_wpdo_post_rc_vendor';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-cache-orchestrator.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/migration/class-tmdo-post-group-reconciler.php',
) as $file ) {
require_once $base . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
foreach ( array( 101 => 'hp_listing', 102 => 'hp_listing', 103 => 'hp_listing', 104 => 'hp_listing', 202 => 'hp_vendor' ) as $id => $type ) {
$wpdb->insert( $wpdb->posts, array( 'ID' => $id, 'post_type' => $type ) );
}
$wpdb->query( 'CREATE TABLE `' . self::LISTING . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, price DECIMAL(18,6) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::VENDOR . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
$wpdb->insert( self::VENDOR, array( 'post_id' => 101, 'shared_key' => 'live' ) );
$wpdb->insert( self::VENDOR, array( 'post_id' => 102, 'shared_key' => 'only-in-vendor' ) );
$wpdb->query( 'INSERT INTO `' . self::VENDOR . "` (post_id, shared_key) VALUES (104, NULL)" );
$wpdb->insert( self::VENDOR, array( 'post_id' => 202, 'shared_key' => 'vendor own value' ) );
$wpdb->insert( self::LISTING, array( 'post_id' => 101, 'shared_key' => 'backfill', 'price' => 5 ) );
$wpdb->insert( self::LISTING, array( 'post_id' => 103, 'shared_key' => 'keep' ) );
$wpdb->insert( self::LISTING, array( 'post_id' => 104, 'shared_key' => 'backfill-104' ) );
// Historical rows: older than any run, so the catch-up pass leaves them alone.
$wpdb->query( 'UPDATE `' . self::VENDOR . "` SET updated_at = '2020-01-01 00:00:00'" );
$wpdb->query( 'UPDATE `' . self::LISTING . "` SET updated_at = '2020-01-01 00:00:00'" );
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '0' );
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group( 'post', 'rc_listing', array( array( 'key' => 'shared_key', 'type' => 'text' ), array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
WPDO_Entity_Registry::register_group( 'post', 'rc_vendor', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) );
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->posts, self::LISTING, self::VENDOR ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function listing_value( int $post_id ): ?string {
global $wpdb;
return $wpdb->get_var( $wpdb->prepare( 'SELECT shared_key FROM `' . self::LISTING . '` WHERE post_id = %d', $post_id ) );
}
public function test_dry_run_reports_without_writing(): void {
$report = TMDO_Post_Group_Reconciler::run( true );
$this->assertSame( 2, $report['moves'][0]['copied'], '101 and 102 hold non-NULL legacy values' );
$this->assertSame( 1, $report['moves'][0]['overwritten'], '101 has a different listing value' );
$this->assertFalse( $report['enabled'] );
$this->assertSame( 'backfill', $this->listing_value( 101 ) );
$this->assertNull( $this->listing_value( 102 ) );
$this->assertSame( '0', get_option( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ) );
}
public function test_run_moves_the_values_reads_returned_before_the_switch(): void {
$report = TMDO_Post_Group_Reconciler::run( false, 1 );
$this->assertSame( 'live', $this->listing_value( 101 ) );
$this->assertSame( 'only-in-vendor', $this->listing_value( 102 ) );
$this->assertSame( 'keep', $this->listing_value( 103 ) );
$this->assertSame( 'backfill-104', $this->listing_value( 104 ), 'NULL legacy values do not erase the listing value' );
$this->assertNull( $this->listing_value( 202 ), 'vendor posts are not copied into the listing table' );
$this->assertSame( 2, $report['moves'][0]['copied'] );
}
public function test_other_columns_of_the_target_row_are_untouched(): void {
global $wpdb;
TMDO_Post_Group_Reconciler::run( false );
$this->assertSame( 5.0, (float) $wpdb->get_var( 'SELECT price FROM `' . self::LISTING . '` WHERE post_id = 101' ) );
}
public function test_completion_turns_scoped_routing_on_and_clears_the_cursor(): void {
$report = TMDO_Post_Group_Reconciler::run( false );
$this->assertTrue( $report['enabled'] );
$this->assertSame( '1', get_option( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ) );
$this->assertFalse( get_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION ) );
}
public function test_catch_up_copies_rows_written_during_the_run(): void {
global $wpdb;
// Simulates a legacy-routed write that lands after the batch passed post 101.
$wpdb->query( 'UPDATE `' . self::VENDOR . "` SET shared_key = 'written during run', updated_at = NOW() + INTERVAL 1 HOUR WHERE post_id = 101" );
update_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION, array( 'rc_vendor>rc_listing:shared_key' => 101 ) );
TMDO_Post_Group_Reconciler::run( false );
$this->assertSame( 'written during run', $this->listing_value( 101 ) );
}
public function test_resumes_after_the_stored_cursor(): void {
update_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION, array( 'rc_vendor>rc_listing:shared_key' => 101 ) );
TMDO_Post_Group_Reconciler::run( false );
$this->assertSame( 'backfill', $this->listing_value( 101 ), 'rows up to the cursor were already done' );
$this->assertSame( 'only-in-vendor', $this->listing_value( 102 ) );
}
}
+1
View File
@@ -93,6 +93,7 @@ class PostMigrationTest extends TestCase {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Post_Fields::register_entity_fields();
tmdo_test_register_wc_product_group();
}
public static function tearDownAfterClass(): void {
@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Routed searches filter status and sort by date on the flat table's
* post_status / post_date copies (O-1), when those copies are trustworthy.
*/
class PostQueryRouterCopyTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_post_rc_core';
private const ALIAS = '`wpdoflat_rc_item_rc_core`';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/engine/class-tmdo-post-status-sync.php',
'includes/query/class-tmdo-post-query-router.php',
) as $file ) {
require_once $base . $file;
}
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date_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')" );
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'ready' ) );
$GLOBALS['_wp_is_admin'] = false;
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
TMDO_Post_Query_Router::flush_copy_cache();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group( 'post', 'rc_core', array( array( 'key' => 'rc_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'rc_item' ) ) );
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
WPDO_Entity_Registry::init();
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, null );
}
/**
* 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 {
$query = new WP_Query();
foreach ( array_merge(
array(
'post_type' => 'rc_item',
'post_status' => 'publish',
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ),
),
$vars
) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
return array(
'join' => $router->posts_join( '', $query ),
'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ),
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
);
}
private function assert_legacy_shape( array $sql ): void {
$this->assertStringContainsString( 'LEFT JOIN', $sql['join'] );
$this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
}
public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void {
$sql = $this->sql( array( 'order' => 'ASC' ) );
$this->assertStringContainsString( 'INNER JOIN', $sql['join'] );
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] );
$this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' );
$this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] );
}
public function test_several_explicit_statuses_are_kept(): void {
$sql = $this->sql( array( 'post_status' => array( 'publish', 'private' ), 'orderby' => 'date' ) );
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish','private')", $sql['where'] );
$this->assertSame( self::ALIAS . '.`post_date` DESC', $sql['orderby'] );
}
public function test_copies_not_marked_ready_keep_the_legacy_shape(): void {
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'columns' ) );
$this->assert_legacy_shape( $this->sql( array() ) );
}
public function test_rows_with_missing_copies_keep_the_legacy_shape(): void {
global $wpdb;
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, rc_price) VALUES (2, 12)' );
$this->assert_legacy_shape( $this->sql( array() ) );
}
public function test_other_orderings_or_implicit_statuses_keep_the_legacy_shape(): void {
$this->assert_legacy_shape( $this->sql( array( 'orderby' => 'title' ) ) );
$this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) );
$this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) );
}
public function test_not_exists_keeps_the_legacy_shape(): void {
// The INNER JOIN would drop posts without a flat row, which NOT EXISTS must match.
$this->assert_legacy_shape( $this->sql( array( 'meta_query' => array( array( 'key' => 'rc_price', 'compare' => 'NOT EXISTS' ) ) ) ) );
}
}
@@ -0,0 +1,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' ) );
}
}
@@ -0,0 +1,379 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Routed searches drop SQL_CALC_FOUND_ROWS; the total comes from a COUNT
* cached until posts' last_changed moves (O-4).
*
* Three fr_item posts, prices 10 / 20 / 30.
*/
class PostQueryRouterFoundRowsTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_post_fr_core';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-cache-orchestrator.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/engine/class-tmdo-hook-bus.php',
'includes/engine/class-tmdo-post-status-sync.php',
'includes/query/class-tmdo-post-query-router.php',
) as $file ) {
require_once $base . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, fr_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type) VALUES (1, 'fr_item'), (2, 'fr_item'), (3, 'fr_item')" );
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, fr_price) VALUES (1, 10), (2, 20), (3, 30)' );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_cache'] = array();
$GLOBALS['_wp_last_changed'] = array();
$GLOBALS['_wp_is_admin'] = false;
$GLOBALS['_wp_current_filter'] = '';
$GLOBALS['_wp_post_types'] = array( 1 => 'fr_item', 2 => 'fr_item', 3 => 'fr_item' );
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
TMDO_Post_Query_Router::flush_copy_cache();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group( 'post', 'fr_core', array( array( 'key' => 'fr_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'fr_item' ) ) );
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, null );
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->posts, self::TABLE ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
/** A routed query plus the SQL WordPress would build for it (page size 1). */
private function routed( array $clause = array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=', 'type' => 'NUMERIC' ) ): array {
global $wpdb;
$query = new WP_Query();
$query->set( 'post_type', 'fr_item' );
$query->set( 'post_status', 'publish' );
$query->set( 'meta_query', array( $clause ) );
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
$request = "SELECT SQL_CALC_FOUND_ROWS {$wpdb->posts}.ID FROM {$wpdb->posts} " . $router->posts_join( '', $query )
. " WHERE 1=1 AND {$wpdb->posts}.post_type = 'fr_item' AND (({$wpdb->posts}.post_status = 'publish'))" . $router->posts_where( '', $query )
. " GROUP BY {$wpdb->posts}.ID ORDER BY {$wpdb->posts}.post_date DESC LIMIT 0, 1";
return array( $router, $query, $request );
}
public function test_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 {
list( $router, $query, $request ) = $this->routed();
$sql = $router->posts_request( $request, $query );
$this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $sql );
$this->assertStringContainsString( 'LIMIT 0, 1', $sql );
}
public function test_unrouted_request_is_left_alone(): void {
$router = new TMDO_Post_Query_Router();
$request = 'SELECT SQL_CALC_FOUND_ROWS wp_itest_posts.ID FROM wp_itest_posts WHERE 1=1 LIMIT 0, 10';
$this->assertSame( $request, $router->posts_request( $request, new WP_Query() ) );
$this->assertSame( 'SELECT FOUND_ROWS()', $router->found_posts_query( 'SELECT FOUND_ROWS()', new WP_Query() ) );
}
public function test_total_is_counted_once_and_served_from_cache_until_posts_change(): void {
global $wpdb;
list( $router, $query, $request ) = $this->routed();
$router->posts_request( $request, $query );
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) );
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 2' );
$this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'cached' );
wp_cache_set_posts_last_changed(); // 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' );
}
/**
* 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 {
$GLOBALS['_wp_current_filter'] = 'update_post_metadata';
$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', '' );
$after_write = wp_cache_get_last_changed( 'posts' );
$GLOBALS['_wp_current_filter'] = 'delete_post_metadata';
WPDO_Hook_Bus::intercept_delete( null, 1, 'fr_price', '', false );
$this->assertNotSame( $before, $after_write );
$this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) );
$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'] );
}
}

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